Newer
Older
<?php
declare(strict_types = 1);
namespace Drupal\Tests\automatic_updates\Kernel;
use Drupal\automatic_updates\CronUpdater;
use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;

Adam G-H
committed
use Drupal\Core\DependencyInjection\ContainerBuilder;

Kunal Sachdev
committed
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;

Kunal Sachdev
committed
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Exception\StageEventException;

Yash Rode
committed
use Drupal\package_manager\Exception\StageOwnershipException;

Kunal Sachdev
committed
use Drupal\package_manager\ValidationResult;
use Drupal\package_manager_bypass\LoggingCommitter;
use Drupal\Tests\automatic_updates\Traits\EmailNotificationsTestTrait;

Ted Bowman
committed
use Drupal\Tests\package_manager\Kernel\TestStage;

Kunal Sachdev
committed
use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;

Theresa Grannum
committed
use Drupal\Tests\user\Traits\UserCreationTrait;

Adam G-H
committed
use Prophecy\Argument;
use ColinODell\PsrTestLogger\TestLogger;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* @covers \Drupal\automatic_updates\CronUpdater
* @covers \automatic_updates_form_update_settings_alter
* @group automatic_updates

Yash Rode
committed
* @internal
*/
class CronUpdaterTest extends AutomaticUpdatesKernelTestBase {
use EmailNotificationsTestTrait;

Kunal Sachdev
committed
use PackageManagerBypassTestTrait;

Theresa Grannum
committed
use UserCreationTrait;

Kunal Sachdev
committed
/**
* {@inheritdoc}
*/
protected static $modules = [
'automatic_updates',

Kunal Sachdev
committed
'automatic_updates_test',

Adam G-H
committed
'user',
];
/**
* The test logger.
*
* @var \ColinODell\PsrTestLogger\TestLogger
*/
private $logger;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->logger = new TestLogger();
$this->container->get('logger.factory')
->get('automatic_updates')
->addLogger($this->logger);

Theresa Grannum
committed
$this->installEntitySchema('user');
$this->installSchema('user', ['users_data']);

Kunal Sachdev
committed
$this->setUpEmailRecipients();
}

Adam G-H
committed
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
parent::register($container);
// Since this test dynamically adds additional loggers to certain channels,
// we need to ensure they will persist even if the container is rebuilt when
// staged changes are applied.
// @see ::testStageDestroyedOnError()
$container->getDefinition('logger.factory')->addTag('persist');
}
/**

omkar podey
committed
* Data provider for testUpdaterCalled().

Rahul Gupta
committed
* @return mixed[][]

omkar podey
committed
* The test cases.
*/
public function providerUpdaterCalled(): array {
$fixture_dir = __DIR__ . '/../../../package_manager/tests/fixtures/release-history';
return [
'disabled, normal release' => [
CronUpdater::DISABLED,
['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
FALSE,
],
'disabled, security release' => [
CronUpdater::DISABLED,
['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
FALSE,
],
'security only, security release' => [
CronUpdater::SECURITY,
['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
TRUE,
],
'security only, normal release' => [
CronUpdater::SECURITY,
['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
FALSE,
],
'enabled, normal release' => [
CronUpdater::ALL,
['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
TRUE,
],
'enabled, security release' => [
CronUpdater::ALL,
['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
TRUE,
],
];
}
/**
* Tests that the cron handler calls the updater as expected.
*
* @param string $setting
* Whether automatic updates should be enabled during cron. Possible values
* are 'disable', 'security', and 'patch'.
* 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 providerUpdaterCalled
*/
public function testUpdaterCalled(string $setting, array $release_data, bool $will_update): void {

Ted Bowman
committed
$version = strpos($release_data['drupal'], '9.8.2') ? '9.8.2' : '9.8.1';
if ($will_update) {

Ted Bowman
committed
$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);

Kunal Sachdev
committed
$this->setCoreVersion('9.8.0');
update_get_available(TRUE);
$this->config('automatic_updates.settings')->set('cron', $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);

Adam G-H
committed
$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());
}
/**

omkar podey
committed
* Data provider for testStageDestroyedOnError().
*

Rahul Gupta
committed
* @return string[][]

omkar podey
committed
* The test cases.
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
*/
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) {

Ted Bowman
committed
$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('cron', CronUpdater::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 updater, 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\CronUpdater $updater */
$updater = $this->container->get('automatic_updates.cron_updater');
// 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, $updater);
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($updater->isAvailable());
$this->container->get('cron')->run();
$logged_by_updater = $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 updater, but not the main cron service).
// But if a pre- or post-destroy event throws an exception, the cron updater
// 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, $updater->isAvailable());
}
else {
$this->assertTrue($updater->isAvailable());
}
$this->assertTrue($logged_by_updater);
$this->assertFalse($logged_by_cron);
}

Ted Bowman
committed
/**
* Tests stage is destroyed if not available and site is on insecure version.
*/
public function testStageDestroyedIfNotAvailable(): void {
$stage = $this->createStage();

Yash Rode
committed
$stage_id = $stage->create();

Ted Bowman
committed
$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);

Ted Bowman
committed
$this->assertDirectoryExists($cron_stage_dir);
};

Kunal Sachdev
committed
$this->addEventTestListener($listener, PostRequireEvent::class);

Ted Bowman
committed
$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));

Yash Rode
committed
$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);

Ted Bowman
committed
}
/**
* Tests stage is not destroyed if another update is applying.
*/
public function testStageNotDestroyedIfApplying(): void {
$this->config('automatic_updates.settings')->set('cron', CronUpdater::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.

Kunal Sachdev
committed
$this->addEventTestListener(function (PreApplyEvent $event) use ($stop_error) {

Ted Bowman
committed
// Ensure the stage that is applying the operation is not the cron
// updater.
$this->assertInstanceOf(TestStage::class, $event->stage);

Ted Bowman
committed
$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]);

Kunal Sachdev
committed
});

Ted Bowman
committed
try {
$stage->apply();
$this->fail('Expected update to fail');
}
catch (StageEventException $exception) {
$this->assertExpectedResultsFromException([ValidationResult::createError([$stop_error])], $exception);

Ted Bowman
committed
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
}
$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('cron', CronUpdater::ALL)->save();
$this->setReleaseMetadata([
'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml",
]);
$this->setCoreVersion('9.8.1');
$stage = $this->createStage();
$stage->create();
$stage->require(['drupal/random']);
$this->assertUpdateStagedTimes(1);
// Trigger CronUpdater, 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);
}

Adam G-H
committed
/**

Theresa Grannum
committed
* Tests that CronUpdater::begin() unconditionally throws an exception.

Adam G-H
committed
*/

Theresa Grannum
committed
public function testBeginThrowsException(): void {
$this->expectExceptionMessage(CronUpdater::class . '::begin() cannot be called directly.');

Adam G-H
committed
$this->container->get('automatic_updates.cron_updater')
->begin(['drupal' => '9.8.1']);
}

Adam G-H
committed
/**

Kunal Sachdev
committed
* Tests that email is sent when an unattended update succeeds.

Adam G-H
committed
*/
public function testEmailOnSuccess(): void {

Ted Bowman
committed
$this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');

Kunal Sachdev
committed
$this->container->get('cron')->run();

Theresa Grannum
committed

Kunal Sachdev
committed
// Ensure we sent a success message to all recipients.
$expected_body = <<<END
Congratulations!

Theresa Grannum
committed
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);

Kunal Sachdev
committed
}
/**
* 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,
],

Theresa Grannum
committed
];

Kunal Sachdev
committed
}

Theresa Grannum
committed

Kunal Sachdev
committed
/**
* Tests the failure e-mail when an unattended non-security update fails.

Kunal Sachdev
committed
*
* @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) {

Ted Bowman
committed
$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('cron', CronUpdater::ALL)
->save();
$error = ValidationResult::createError([
t('Error while updating!'),
]);
$exception = $this->createStageEventExceptionFromResults([$error], $event_class, $this->container->get('automatic_updates.cron_updater'));
TestSubscriber1::setTestResult($exception->event->getResults(), $event_class);
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
$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) {

Ted Bowman
committed
$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('automatic_updates.cron_updater'));
$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 {

Ted Bowman
committed
$this->getStageFixtureManipulator()->setCorePackageVersion('9.8.1');
$error = new \Exception('I drink your milkshake!');
LoggingCommitter::setException($error);

Adam G-H
committed
$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:

Adam G-H
committed
Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a 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);
}

Kunal Sachdev
committed
/**
* Tests that setLogger is called on the cron updater service.
*/
public function testLoggerIsSetByContainer(): void {
$updater_method_calls = $this->container->getDefinition('automatic_updates.cron_updater')->getMethodCalls();
$this->assertSame('setLogger', $updater_method_calls[0][0]);
}