Newer
Older
<?php
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;
use Drupal\Core\Form\FormState;

Kunal Sachdev
committed
use Drupal\Core\Logger\RfcLogLevel;
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\StageValidationException;

Kunal Sachdev
committed
use Drupal\package_manager\ValidationResult;
use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
use Drupal\update\UpdateSettingsForm;

Kunal Sachdev
committed
use Psr\Log\Test\TestLogger;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* @covers \Drupal\automatic_updates\CronUpdater
* @covers \automatic_updates_form_update_settings_alter
*
* @group automatic_updates
*/
class CronUpdaterTest extends AutomaticUpdatesKernelTestBase {

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

Kunal Sachdev
committed
'automatic_updates_test',
];
/**
* The test logger.
*
* @var \Psr\Log\Test\TestLogger
*/
private $logger;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
// Because package_manager_bypass is enabled, a staging directory will not
// actually exist. Therefore, we need to disable these validators because
// they attempt to compare the active and stage directories.
$this->disableValidators[] = 'automatic_updates.validator.staged_database_updates';
$this->disableValidators[] = 'automatic_updates.staged_projects_validator';
parent::setUp();
$this->logger = new TestLogger();
$this->container->get('logger.factory')
->get('automatic_updates')
->addLogger($this->logger);
}

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');
}
/**
* Data provider for ::testUpdaterCalled().
*
* @return array[]
* Sets of arguments to pass to the test method.
*/
public function providerUpdaterCalled(): array {
$fixture_dir = __DIR__ . '/../../fixtures/release-history';
return [
'disabled, normal release' => [
CronUpdater::DISABLED,

Kunal Sachdev
committed
"$fixture_dir/drupal.9.8.2.xml",
FALSE,
],
'disabled, security release' => [
CronUpdater::DISABLED,
"$fixture_dir/drupal.9.8.1-security.xml",
FALSE,
],
'security only, security release' => [
CronUpdater::SECURITY,
"$fixture_dir/drupal.9.8.1-security.xml",
TRUE,
],
'security only, normal release' => [
CronUpdater::SECURITY,

Kunal Sachdev
committed
"$fixture_dir/drupal.9.8.2.xml",
FALSE,
],
'enabled, normal release' => [
CronUpdater::ALL,

Kunal Sachdev
committed
"$fixture_dir/drupal.9.8.2.xml",

Ted Bowman
committed
FALSE,
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
],
'enabled, security release' => [
CronUpdater::ALL,
"$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'.
* @param string $release_data
* If automatic updates are enabled, the path of the fake release metadata
* that should be served when fetching information on available updates.
* @param bool $will_update
* Whether an update should be performed, given the previous two arguments.
*
* @dataProvider providerUpdaterCalled
*/
public function testUpdaterCalled(string $setting, string $release_data, bool $will_update): void {
// 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);
// Submit the configuration form programmatically, to prove our alterations
// work as expected.
$form_builder = $this->container->get('form_builder');
$form_state = new FormState();
$form = $form_builder->buildForm(UpdateSettingsForm::class, $form_state);
// Ensure that the version ranges in the setting's description, which are
// computed dynamically, look correct.
$this->assertStringContainsString('Automatic updates are only supported for 9.8.x versions of Drupal core. Drupal 9.8 will receive security updates until 9.10.0 is released.', $form['automatic_updates_cron']['#description']);
$form_state->setValue('automatic_updates_cron', $setting);
$form_builder->submitForm(UpdateSettingsForm::class, $form_state);
// 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);
$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, then there will be two calls to the stager: one to
// change the constraints in composer.json, and another to actually update
// the installed dependencies.
$this->assertCount($will_update * 2, $this->container->get('package_manager.stager')->getInvocationArguments());
$this->assertCount($will_update, $this->container->get('package_manager.committer')->getInvocationArguments());
}
174
175
176
177
178
179
180
181
182
183
184
185
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
226
227
228
229
230
231
232
233
234
235
/**
* Data provider for ::testStageDestroyedOnError().
*
* @return array[]
* Sets of arguments to pass to the test method.
*/
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,
StageValidationException::class,
],
'pre-require validation error' => [
PreRequireEvent::class,
StageValidationException::class,
],
'pre-apply validation error' => [
PreApplyEvent::class,
StageValidationException::class,
],
'pre-destroy validation error' => [
PreDestroyEvent::class,
StageValidationException::class,
],
];
}

Kunal Sachdev
committed
/**
* Data provider for testErrors().
*
* @return array[]
* The test cases for testErrors().
*/
public function providerErrors(): array {
$messages = [
'PreCreate Event Error',
'PreCreate Event Error 2',

Kunal Sachdev
committed
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
];
$summary = 'There were errors in updates';
$result_no_summary = ValidationResult::createError([$messages[0]]);
$result_with_summary = ValidationResult::createError($messages, t($summary));
$result_with_summary_message = "<h3>{$summary}</h3><ul><li>{$messages[0]}</li><li>{$messages[1]}</li></ul>";
return [
'1 result with summary' => [
[$result_with_summary],
$result_with_summary_message,
],
'2 results with summary' => [
[$result_with_summary, $result_with_summary],
"$result_with_summary_message$result_with_summary_message",
],
'1 result without summary' => [
[$result_no_summary],
$messages[0],
],
'2 results without summary' => [
[$result_no_summary, $result_no_summary],
$messages[0] . ' ' . $messages[0],
],
'1 result with summary, 1 result without summary' => [
[$result_with_summary, $result_no_summary],
$result_with_summary_message . ' ' . $messages[0],
],
];
}
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
/**
* 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 {
$this->installConfig('automatic_updates');
$this->setCoreVersion('9.8.0');
// Ensure that there is a security release to which we should update.
$this->setReleaseMetadata(__DIR__ . "/../../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);
// When the event specified by $event_class is fired, either throw an
// exception directly from the event subscriber, or set a validation error
// (if the exception class is StageValidationException).
if ($exception_class === StageValidationException::class) {
$results = [
ValidationResult::createError(['Destroy the stage!']),
];
TestSubscriber1::setTestResult($results, $event_class);
$exception = new StageValidationException($results, 'Unable to complete the update because of errors.');
$expected_log_message = TestCronUpdater::formatValidationException($exception);
}
else {
/** @var \Throwable $exception */
$exception = new $exception_class('Destroy the stage!');
TestSubscriber1::setException($exception, $event_class);
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
$expected_log_message = $exception->getMessage();
}
// Ensure that nothing has been logged yet.
$this->assertEmpty($cron_logger->records);
$this->assertEmpty($this->logger->records);
/** @var \Drupal\automatic_updates\CronUpdater $updater */
$updater = $this->container->get('automatic_updates.cron_updater');
$this->assertTrue($updater->isAvailable());
$this->container->get('cron')->run();
$logged_by_updater = $this->logger->hasRecord($expected_log_message, 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, 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 ($exception instanceof StageValidationException) {
$this->assertTrue($logged_by_updater);
$this->assertFalse($logged_by_cron);
}
else {
$this->assertFalse($logged_by_updater);
$this->assertTrue($logged_by_cron);
}
// 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());
}
// For all other events, the error should be caught and logged by the cron
// updater, not the main cron service, and the stage should always be
// destroyed and marked as available.
else {
$this->assertTrue($logged_by_updater);
$this->assertFalse($logged_by_cron);
$this->assertTrue($updater->isAvailable());
}
}

Kunal Sachdev
committed
/**
* Tests errors during a cron update attempt.
*
* @param \Drupal\package_manager\ValidationResult[] $validation_results
* The expected validation results which should be logged.
* @param string $expected_log_message
* The error message should be logged.
*
* @dataProvider providerErrors
*/
public function testErrors(array $validation_results, string $expected_log_message): void {
TestSubscriber1::setTestResult($validation_results, PreCreateEvent::class);

Kunal Sachdev
committed
$this->container->get('cron')->run();
$this->assertUpdateStagedTimes(0);
$this->assertTrue($this->logger->hasRecord("<h2>Unable to complete the update because of errors.</h2>$expected_log_message", RfcLogLevel::ERROR));

Kunal Sachdev
committed
}