<?php namespace Drupal\Tests\automatic_updates\Functional; use Behat\Mink\Element\NodeElement; use Drupal\automatic_updates\Event\ReadinessCheckEvent; use Drupal\automatic_updates_test\Datetime\TestTime; use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1; use Drupal\automatic_updates_test2\EventSubscriber\TestSubscriber2; use Drupal\Core\Url; use Drupal\system\SystemManager; use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait; use Drupal\Tests\Traits\Core\CronRunTrait; /** * Tests readiness validation. * * @group automatic_updates */ class ReadinessValidationTest extends AutomaticUpdatesFunctionalTestBase { use CronRunTrait; use ValidationTestTrait; /** * {@inheritdoc} */ protected $defaultTheme = 'stark'; /** * A user who can view the status report. * * @var \Drupal\user\Entity\User */ protected $reportViewerUser; /** * A user who can view the status report and run readiness checkers. * * @var \Drupal\user\Entity\User */ protected $checkerRunnerUser; /** * The test checker. * * @var \Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1 */ protected $testChecker; /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml'); $this->setCoreVersion('9.8.1'); $this->reportViewerUser = $this->createUser([ 'administer site configuration', 'access administration pages', ]); $this->checkerRunnerUser = $this->createUser([ 'administer site configuration', 'administer software updates', 'access administration pages', 'access site in maintenance mode', ]); $this->createTestValidationResults(); $this->drupalLogin($this->reportViewerUser); } /** * Tests readiness checkers on status report page. */ public function testReadinessChecksStatusReport(): void { $assert = $this->assertSession(); // Ensure automated_cron is disabled before installing automatic_updates. This // ensures we are testing that automatic_updates runs the checkers when the // module itself is installed and they weren't run on cron. $this->assertFalse($this->container->get('module_handler')->moduleExists('automated_cron')); $this->container->get('module_installer')->install(['automatic_updates', 'automatic_updates_test']); // If the site is ready for updates, the users will see the same output // regardless of whether the user has permission to run updates. $this->drupalLogin($this->reportViewerUser); $this->checkForUpdates(); $this->drupalGet('admin/reports/status'); $this->assertNoErrors(); $this->drupalLogin($this->checkerRunnerUser); $this->drupalGet('admin/reports/status'); $this->assertNoErrors(TRUE); // Confirm a user without the permission to run readiness checks does not // have a link to run the checks when the checks need to be run again. // @todo Change this to fake the request time in // https://www.drupal.org/node/3113971. /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */ $key_value = $this->container->get('keyvalue.expirable')->get('automatic_updates'); $key_value->delete('readiness_validation_last_run'); $this->drupalLogin($this->reportViewerUser); $this->drupalGet('admin/reports/status'); $this->assertNoErrors(); $this->drupalLogin($this->checkerRunnerUser); $this->drupalGet('admin/reports/status'); $this->assertNoErrors(TRUE); // Confirm a user with the permission to run readiness checks does have a // link to run the checks when the checks need to be run again. $this->drupalLogin($this->reportViewerUser); $this->drupalGet('admin/reports/status'); $this->assertNoErrors(); $this->drupalLogin($this->checkerRunnerUser); $this->drupalGet('admin/reports/status'); $this->assertNoErrors(TRUE); /** @var \Drupal\package_manager\ValidationResult[] $expected_results */ $expected_results = $this->testResults['checker_1']['1 error']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); // Run the readiness checks. $this->clickLink('Run readiness checks'); $assert->statusCodeEquals(200); // Confirm redirect back to status report page. $assert->addressEquals('/admin/reports/status'); // Assert that when the runners are run manually the message that updates // will not be performed because of errors is displayed on the top of the // page in message. $assert->pageTextMatchesCount(2, '/' . preg_quote(static::$errorsExplanation) . '/'); $this->assertErrors($expected_results, TRUE); // @todo Should we always show when the checks were last run and a link to // run when there is an error? // Confirm a user without permission to run the checks sees the same error. $this->drupalLogin($this->reportViewerUser); $this->drupalGet('admin/reports/status'); $this->assertErrors($expected_results); $expected_results = $this->testResults['checker_1']['1 error 1 warning']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); $key_value->delete('readiness_validation_last_run'); // Confirm a new message is displayed if the stored messages are deleted. $this->drupalGet('admin/reports/status'); // Confirm that on the status page if there is only 1 warning or error the // the summaries will not be displayed. $this->assertErrors([$expected_results['1:error']]); $this->assertWarnings([$expected_results['1:warning']]); $assert->pageTextNotContains($expected_results['1:error']->getSummary()); $assert->pageTextNotContains($expected_results['1:warning']->getSummary()); $key_value->delete('readiness_validation_last_run'); $expected_results = $this->testResults['checker_1']['2 errors 2 warnings']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); $this->drupalGet('admin/reports/status'); // Confirm that both messages and summaries will be displayed on status // report when there multiple messages. $this->assertErrors([$expected_results['1:errors']]); $this->assertWarnings([$expected_results['1:warnings']]); $key_value->delete('readiness_validation_last_run'); $expected_results = $this->testResults['checker_1']['2 warnings']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); $this->drupalGet('admin/reports/status'); $assert->pageTextContainsOnce('Update readiness checks'); // Confirm that warnings will display on the status report if there are no // errors. $this->assertWarnings($expected_results); $key_value->delete('readiness_validation_last_run'); $expected_results = $this->testResults['checker_1']['1 warning']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); $this->drupalGet('admin/reports/status'); $assert->pageTextContainsOnce('Update readiness checks'); $this->assertWarnings($expected_results); } /** * Tests readiness checkers results on admin pages.. */ public function testReadinessChecksAdminPages(): void { $assert = $this->assertSession(); $messages_section_selector = '[data-drupal-messages]'; // Ensure automated_cron is disabled before installing automatic_updates. This // ensures we are testing that automatic_updates runs the checkers when the // module itself is installed and they weren't run on cron. $this->assertFalse($this->container->get('module_handler')->moduleExists('automated_cron')); $this->container->get('module_installer')->install(['automatic_updates', 'automatic_updates_test']); // If site is ready for updates no message will be displayed on admin pages. $this->drupalLogin($this->reportViewerUser); $this->drupalGet('admin/reports/status'); $this->assertNoErrors(); $this->drupalGet('admin/structure'); $assert->elementNotExists('css', $messages_section_selector); // Confirm a user without the permission to run readiness checks does not // have a link to run the checks when the checks need to be run again. $expected_results = $this->testResults['checker_1']['1 error']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); // @todo Change this to use ::delayRequestTime() to simulate running cron // after a 24 wait instead of directly deleting 'readiness_validation_last_run' // https://www.drupal.org/node/3113971. /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */ $key_value = $this->container->get('keyvalue.expirable')->get('automatic_updates'); $key_value->delete('readiness_validation_last_run'); // A user without the permission to run the checkers will not see a message // on other pages if the checkers need to be run again. $this->drupalGet('admin/structure'); $assert->elementNotExists('css', $messages_section_selector); // Confirm that a user with the correct permission can also run the checkers // on another admin page. $this->drupalLogin($this->checkerRunnerUser); $this->drupalGet('admin/structure'); $assert->elementExists('css', $messages_section_selector); $assert->pageTextContainsOnce('Your site has not recently run an update readiness check. Run readiness checks now.'); $this->clickLink('Run readiness checks now.'); $assert->addressEquals('admin/structure'); $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]); $expected_results = $this->testResults['checker_1']['1 error 1 warning']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); // Confirm a new message is displayed if the cron is run after an hour. $this->delayRequestTime(); $this->cronRun(); $this->drupalGet('admin/structure'); $assert->pageTextContainsOnce(static::$errorsExplanation); // Confirm on admin pages that a single error will be displayed instead of a // summary. $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['1:error']->getSeverity()); $assert->pageTextContainsOnce($expected_results['1:error']->getMessages()[0]); $assert->pageTextNotContains($expected_results['1:error']->getSummary()); // Warnings are not displayed on admin pages if there are any errors. $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['1:warning']->getSeverity()); $assert->pageTextNotContains($expected_results['1:warning']->getMessages()[0]); $assert->pageTextNotContains($expected_results['1:warning']->getSummary()); // Confirm that if cron runs less than hour after it previously ran it will // not run the checkers again. $unexpected_results = $this->testResults['checker_1']['2 errors 2 warnings']; TestSubscriber1::setTestResult($unexpected_results, ReadinessCheckEvent::class); $this->delayRequestTime(30); $this->cronRun(); $this->drupalGet('admin/structure'); $assert->pageTextNotContains($unexpected_results['1:errors']->getSummary()); $assert->pageTextContainsOnce($expected_results['1:error']->getMessages()[0]); $assert->pageTextNotContains($unexpected_results['1:warnings']->getSummary()); $assert->pageTextNotContains($expected_results['1:warning']->getMessages()[0]); // Confirm that is if cron is run over an hour after the checkers were // previously run the checkers will be run again. $this->delayRequestTime(31); $this->cronRun(); $expected_results = $unexpected_results; $this->drupalGet('admin/structure'); // Confirm on admin pages only the error summary will be displayed if there // is more than 1 error. $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['1:errors']->getSeverity()); $assert->pageTextNotContains($expected_results['1:errors']->getMessages()[0]); $assert->pageTextNotContains($expected_results['1:errors']->getMessages()[1]); $assert->pageTextContainsOnce($expected_results['1:errors']->getSummary()); $assert->pageTextContainsOnce(static::$errorsExplanation); // Warnings are not displayed on admin pages if there are any errors. $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['1:warnings']->getSeverity()); $assert->pageTextNotContains($expected_results['1:warnings']->getMessages()[0]); $assert->pageTextNotContains($expected_results['1:warnings']->getMessages()[1]); $assert->pageTextNotContains($expected_results['1:warnings']->getSummary()); $expected_results = $this->testResults['checker_1']['2 warnings']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); $this->delayRequestTime(); $this->cronRun(); $this->drupalGet('admin/structure'); // Confirm that the warnings summary is displayed on admin pages if there // are no errors. $assert->pageTextNotContains(static::$errorsExplanation); $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results[0]->getSeverity()); $assert->pageTextNotContains($expected_results[0]->getMessages()[0]); $assert->pageTextNotContains($expected_results[0]->getMessages()[1]); $assert->pageTextContainsOnce(static::$warningsExplanation); $assert->pageTextContainsOnce($expected_results[0]->getSummary()); $expected_results = $this->testResults['checker_1']['1 warning']; TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class); $this->delayRequestTime(); $this->cronRun(); $this->drupalGet('admin/structure'); $assert->pageTextNotContains(static::$errorsExplanation); // Confirm that a single warning is displayed and not the summary on admin // pages if there is only 1 warning and there are no errors. $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results[0]->getSeverity()); $assert->pageTextContainsOnce(static::$warningsExplanation); $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]); $assert->pageTextNotContains($expected_results[0]->getSummary()); // Confirm readiness messages are not displayed when cron updates are // disabled. $this->drupalGet(Url::fromRoute('update.settings')); $edit['automatic_updates_cron'] = 'disable'; $this->submitForm($edit, 'Save configuration'); $this->drupalGet('admin/structure'); $assert->pageTextNotContains(static::$warningsExplanation); $assert->pageTextNotContains($expected_results[0]->getMessages()[0]); } /** * Tests installing a module with a checker before installing automatic_updates. */ public function testReadinessCheckAfterInstall(): void { $assert = $this->assertSession(); $this->drupalLogin($this->checkerRunnerUser); $this->drupalGet('admin/reports/status'); $assert->pageTextNotContains('Update readiness checks'); // We have to install the automatic_updates_test module because it provides // the functionality to retrieve our fake release history metadata. $this->container->get('module_installer')->install(['automatic_updates', 'automatic_updates_test']); $this->drupalGet('admin/reports/status'); $this->assertNoErrors(TRUE); $expected_results = $this->testResults['checker_1']['1 error']; TestSubscriber2::setTestResult($expected_results, ReadinessCheckEvent::class); $this->container->get('module_installer')->install(['automatic_updates_test2']); $this->drupalGet('admin/structure'); $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]); // Confirm that installing a module runs the checkers, even if the new // module does not provide any validators. $previous_results = $expected_results; $expected_results = $this->testResults['checker_1']['2 errors 2 warnings']; TestSubscriber2::setTestResult($expected_results, ReadinessCheckEvent::class); $this->container->get('module_installer')->install(['help']); // Check for messages on 'admin/structure' instead of the status report, // because validators will be run if needed on the status report. $this->drupalGet('admin/structure'); // Confirm that new checker messages are displayed. $assert->pageTextNotContains($previous_results[0]->getMessages()[0]); $assert->pageTextNotContains($expected_results['1:errors']->getMessages()[0]); $assert->pageTextContainsOnce($expected_results['1:errors']->getSummary()); } /** * Tests that checker message for an uninstalled module is not displayed. */ public function testReadinessCheckerUninstall(): void { $assert = $this->assertSession(); $this->drupalLogin($this->checkerRunnerUser); $expected_results_1 = $this->testResults['checker_1']['1 error']; TestSubscriber1::setTestResult($expected_results_1, ReadinessCheckEvent::class); $expected_results_2 = $this->testResults['checker_2']['1 error']; TestSubscriber2::setTestResult($expected_results_2, ReadinessCheckEvent::class); $this->container->get('module_installer')->install([ 'automatic_updates', 'automatic_updates_test', 'automatic_updates_test2', ]); // Check for message on 'admin/structure' instead of the status report // because checkers will be run if needed on the status report. $this->drupalGet('admin/structure'); $assert->pageTextContainsOnce($expected_results_1[0]->getMessages()[0]); $assert->pageTextContainsOnce($expected_results_2[0]->getMessages()[0]); // Confirm that when on of the module is uninstalled the other module's // checker result is still displayed. $this->container->get('module_installer')->uninstall(['automatic_updates_test2']); $this->drupalGet('admin/structure'); $assert->pageTextNotContains($expected_results_2[0]->getMessages()[0]); $assert->pageTextContainsOnce($expected_results_1[0]->getMessages()[0]); // Confirm that when on of the module is uninstalled the other module's // checker result is still displayed. $this->container->get('module_installer')->uninstall(['automatic_updates_test']); $this->drupalGet('admin/structure'); $assert->pageTextNotContains($expected_results_2[0]->getMessages()[0]); $assert->pageTextNotContains($expected_results_1[0]->getMessages()[0]); } /** * Tests that stored validation results are deleted after an update. */ public function testStoredResultsClearedAfterUpdate(): void { $assert_session = $this->assertSession(); $page = $this->getSession()->getPage(); $this->drupalLogin($this->checkerRunnerUser); // The current release is 9.8.1 (see ::setUp()), so ensure we're on an older // version. $this->setCoreVersion('9.8.0'); // Flag a validation error, which will be displayed in the messages area. $results = $this->testResults['checker_1']['1 error']; TestSubscriber1::setTestResult($results, ReadinessCheckEvent::class); $message = $results[0]->getMessages()[0]; $this->container->get('module_installer')->install([ 'automatic_updates', 'automatic_updates_test', ]); // Because all actual staging operations are bypassed by // package_manager_bypass (enabled by the parent class), disable this // validator because it will complain if there's no actual Composer data to // inspect. $this->disableValidators(['automatic_updates.staged_projects_validator']); // The error should be persistently visible, even after the checker stops // flagging it. $this->drupalGet('/admin/structure'); $assert_session->pageTextContains($message); TestSubscriber1::setTestResult(NULL, ReadinessCheckEvent::class); $this->getSession()->reload(); $assert_session->pageTextContains($message); // Do the update; we don't expect any errors or special conditions to appear // during it. The Update button is displayed because the form does its own // readiness check (without storing the results), and the checker is no // longer raising an error. $this->drupalGet('/admin/modules/automatic-update'); $assert_session->buttonExists('Update'); // Ensure that the previous results are still displayed on another admin // page, to confirm that the updater form is not discarding the previous // results by doing its checks. $this->drupalGet('/admin/structure'); $assert_session->pageTextContains($message); // Proceed with the update. $this->drupalGet('/admin/modules/automatic-update'); $page->pressButton('Update'); $this->checkForMetaRefresh(); $this->assertUpdateReady(); $page->pressButton('Continue'); $this->checkForMetaRefresh(); $assert_session->pageTextContains('Update complete!'); // The warning should not be visible anymore. $this->drupalGet('/admin/structure'); $assert_session->pageTextNotContains($message); } /** * Asserts that the readiness requirement displays no errors or warnings. * * @param bool $run_link * (optional) Whether there should be a link to run the readiness checks. * Defaults to FALSE. */ private function assertNoErrors(bool $run_link = FALSE): void { $this->assertRequirement('checked', 'Your site is ready for automatic updates.', [], $run_link); } /** * Asserts that the displayed readiness requirement contains warnings. * * @param \Drupal\package_manager\ValidationResult[] $expected_results * The readiness check results that should be visible. * @param bool $run_link * (optional) Whether there should be a link to run the readiness checks. * Defaults to FALSE. */ private function assertWarnings(array $expected_results, bool $run_link = FALSE): void { $this->assertRequirement('warning', static::$warningsExplanation, $expected_results, $run_link); } /** * Asserts that the displayed readiness requirement contains errors. * * @param \Drupal\package_manager\ValidationResult[] $expected_results * The readiness check results that should be visible. * @param bool $run_link * (optional) Whether there should be a link to run the readiness checks. * Defaults to FALSE. */ private function assertErrors(array $expected_results, bool $run_link = FALSE): void { $this->assertRequirement('error', static::$errorsExplanation, $expected_results, $run_link); } /** * Asserts that the readiness requirement is correct. * * @param string $section * The section of the status report in which the requirement is expected to * be. Can be one of 'error', 'warning', 'checked', or 'ok'. * @param string $preamble * The text that should appear before the result messages. * @param \Drupal\package_manager\ValidationResult[] $expected_results * The expected readiness check results, in the order we expect them to be * displayed. * @param bool $run_link * (optional) Whether there should be a link to run the readiness checks. * Defaults to FALSE. * * @see \Drupal\Core\Render\Element\StatusReport::getInfo() */ private function assertRequirement(string $section, string $preamble, array $expected_results, bool $run_link = FALSE): void { // Get the meaty part of the requirement element, and ensure that it begins // with the preamble, if any. $requirement = $this->assertSession() ->elementExists('css', "h3#$section ~ details.system-status-report__entry:contains('Update readiness checks') .system-status-report__entry__value"); if ($preamble) { $this->assertStringStartsWith($preamble, $requirement->getText()); } // Convert the expected results into strings. $expected_messages = []; foreach ($expected_results as $result) { $messages = $result->getMessages(); if (count($messages) > 1) { $expected_messages[] = $result->getSummary(); } $expected_messages = array_merge($expected_messages, $messages); } $expected_messages = array_map('strval', $expected_messages); // The results should appear in the given order. $this->assertSame($expected_messages, $this->getMessagesFromRequirement($requirement)); // Check for the presence or absence of a link to run the checks. $this->assertSame($run_link, $requirement->hasLink('Run readiness checks')); } /** * Extracts the readiness result messages from the requirement element. * * @param \Behat\Mink\Element\NodeElement $requirement * The page element containing the readiness check results. * * @return string[] * The readiness result messages (including summaries), in the order they * appear on the page. */ private function getMessagesFromRequirement(NodeElement $requirement): array { $messages = []; // Each list item will either contain a simple string (for results with only // one message), or a details element with a series of messages. $items = $requirement->findAll('css', 'li'); foreach ($items as $item) { $details = $item->find('css', 'details'); if ($details) { $messages[] = $details->find('css', 'summary')->getText(); $messages = array_merge($messages, $this->getMessagesFromRequirement($details)); } else { $messages[] = $item->getText(); } } return array_unique($messages); } /** * Delays the request for the test. * * @param int $minutes * The number of minutes to delay request time. Defaults to 61 minutes. */ private function delayRequestTime(int $minutes = 61): void { static $total_delay = 0; $total_delay += $minutes; TestTime::setFakeTimeByOffset("+$total_delay minutes"); } }