Newer
Older

Adam G-H
committed
<?php

omkar podey
committed
declare(strict_types = 1);

Adam G-H
committed
namespace Drupal\Tests\package_manager\Kernel;
use ColinODell\PsrTestLogger\TestLogger;
use Drupal\Component\FileSystem\FileSystem as DrupalFileSystem;

Adam G-H
committed
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Logger\RfcLogLevel;
use Drupal\Core\Site\Settings;

Ted Bowman
committed
use Drupal\fixture_manipulator\StageFixtureManipulator;

Adam G-H
committed
use Drupal\KernelTests\KernelTestBase;

Kunal Sachdev
committed
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Exception\StageEventException;
use Drupal\package_manager\FailureMarker;
use Drupal\package_manager\PathLocator;
use Drupal\package_manager\StatusCheckTrait;
use Drupal\package_manager\Validator\DiskSpaceValidator;

Kunal Sachdev
committed
use Drupal\package_manager\StageBase;
use Drupal\system\SystemManager;

Travis Carden
committed
use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait;

Ted Bowman
committed
use Drupal\Tests\package_manager\Traits\FixtureManipulatorTrait;
use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;

Adam G-H
committed
use Drupal\Tests\package_manager\Traits\ValidationTestTrait;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactory;
use Psr\Http\Message\RequestInterface;
use Symfony\Component\Filesystem\Filesystem;

Adam G-H
committed
/**
* Base class for kernel tests of Package Manager's functionality.

Yash Rode
committed
*
* @internal

Adam G-H
committed
*/
abstract class PackageManagerKernelTestBase extends KernelTestBase {

Travis Carden
committed
use AssertPreconditionsTrait;

Ted Bowman
committed
use FixtureManipulatorTrait;
use FixtureUtilityTrait;
use StatusCheckTrait;

Adam G-H
committed
use ValidationTestTrait;
/**
* The mocked HTTP client that returns metadata about available updates.
*
* We need to preserve this as a class property so that we can re-inject it
* into the container when a rebuild is triggered by module installation.
*
* @var \GuzzleHttp\Client
*
* @see ::register()
*/
private $client;

Adam G-H
committed
/**
* {@inheritdoc}
*/
protected static $modules = [

Ted Bowman
committed
'fixture_manipulator',

Adam G-H
committed
'package_manager',
'package_manager_bypass',
'system',
'update',
'update_test',

Adam G-H
committed
];

Ted Bowman
committed
/**
* The service IDs of any validators to disable.
*
* @var string[]
*/
protected $disableValidators = [];

Ted Bowman
committed
/**
* The test root directory, if any, created by ::createTestProject().
*
* @var string|null
*
* @see ::createTestProject()
* @see ::tearDown()
*/
protected ?string $testProjectRoot = NULL;
/**
* The Symfony filesystem class.
*
* @var \Symfony\Component\Filesystem\Filesystem
*/
private readonly Filesystem $fileSystem;
/**
* A logger that will fail the test if Package Manager logs any errors.
*
* @var \ColinODell\PsrTestLogger\TestLogger
*
* @see ::tearDown()
*/
protected readonly TestLogger $failureLogger;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig('package_manager');
$this->fileSystem = new Filesystem();
$this->createTestProject();
// The Update module's default configuration must be installed for our

Adam G-H
committed
// fake release metadata to be fetched, and the System module's to ensure
// the site has a name.
$this->installConfig(['system', 'update']);
// Make the update system think that all of System's post-update functions
// have run.
$this->registerPostUpdateFunctions();
// Ensure we can fail the test if any warnings, or worse, are logged by
// Package Manager.
// @see ::tearDown()
$this->failureLogger = new TestLogger();
$this->container->get('logger.channel.package_manager')
->addLogger($this->failureLogger);
}

Adam G-H
committed
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
parent::register($container);
// If we previously set up a mock HTTP client in ::setReleaseMetadata(),
// re-inject it into the container.
if ($this->client) {
$container->set('http_client', $this->client);
}
// When the test project is used, the disk space validator is replaced with
// a mock. When staged changes are applied, the container is rebuilt, which
// destroys the mocked service and can cause unexpected side effects. The
// 'persist' tag prevents the mock from being destroyed during a container
// rebuild.
// @see ::createTestProject()
$container->getDefinition('package_manager.validator.disk_space')
->addTag('persist');

Adam G-H
committed
// Ensure that our failure logger will survive container rebuilds.
$container->getDefinition('logger.channel.package_manager')
->addTag('persist');

Ted Bowman
committed
foreach ($this->disableValidators as $service_id) {
if ($container->hasDefinition($service_id)) {
$container->getDefinition($service_id)->clearTag('event_subscriber');
}
}

Adam G-H
committed
}

Adam G-H
committed
/**
* Creates a stage object for testing purposes.

Adam G-H
committed
*
* @return \Drupal\Tests\package_manager\Kernel\TestStage
* A stage object, with test-only modifications.

Adam G-H
committed
*/
protected function createStage(): TestStage {
return new TestStage(
$this->container->get(PathLocator::class),

Adam G-H
committed
$this->container->get('package_manager.beginner'),
$this->container->get('package_manager.stager'),
$this->container->get('package_manager.committer'),

Ted Bowman
committed
$this->container->get('file_system'),

Adam G-H
committed
$this->container->get('event_dispatcher'),

Adam G-H
committed
$this->container->get('tempstore.shared'),
$this->container->get('datetime.time'),
new PathFactory(),
$this->container->get(FailureMarker::class)

Adam G-H
committed
);
}
/**
* Asserts validation results are returned from a stage life cycle event.
*
* @param \Drupal\package_manager\ValidationResult[] $expected_results
* The expected validation results.
* @param string|null $event_class
* (optional) The class of the event which should return the results. Must
* be passed if $expected_results is not empty.
*

Kunal Sachdev
committed
* @return \Drupal\package_manager\StageBase
* The stage that was used to collect the validation results.
*/

Kunal Sachdev
committed
protected function assertResults(array $expected_results, string $event_class = NULL): StageBase {
$stage = $this->createStage();

Adam G-H
committed
try {
$stage->create();
$stage->require(['drupal/core:9.8.1']);
$stage->apply();
$stage->postApply();

Adam G-H
committed
$stage->destroy();
// If we did not get an exception, ensure we didn't expect any results.
$this->assertValidationResultsEqual([], $expected_results);

Adam G-H
committed
}
catch (StageEventException $e) {
$this->assertNotEmpty($expected_results);

Ted Bowman
committed
$this->assertInstanceOf($event_class, $e->event);
$this->assertExpectedResultsFromException($expected_results, $e);

Adam G-H
committed
}
return $stage;

Adam G-H
committed
}
/**
* Asserts validation results are returned from the status check event.
*
* @param \Drupal\package_manager\ValidationResult[] $expected_results
* The expected validation results.
* @param \Drupal\Tests\package_manager\Kernel\TestStage|null $stage
* (optional) The test stage to use to create the status check event. If
* none is provided a new stage will be created.

Kunal Sachdev
committed
protected function assertStatusCheckResults(array $expected_results, StageBase $stage = NULL): void {
$actual_results = $this->runStatusCheck($stage ?? $this->createStage(), $this->container->get('event_dispatcher'));
$this->assertValidationResultsEqual($expected_results, $actual_results);

Ted Bowman
committed
/**
* Marks all pending post-update functions as completed.
*
* Since kernel tests don't normally install modules and register their
* updates, this method makes sure that we are testing from a clean, fully
* up-to-date state.
*/
protected function registerPostUpdateFunctions(): void {
$updates = $this->container->get('update.post_update_registry')
->getPendingUpdateFunctions();
$this->container->get('keyvalue')
->get('post_update')
->set('existing_updates', $updates);
}
* Creates a test project.
* This will create a temporary uniques root directory and then creates two
* directories in it:
* 'active', which is the active directory containing a fake Drupal code base,
* and 'stage', which is the root directory used to stage changes. The path
* locator service will also be mocked so that it points to the test project.
*
* @param string|null $source_dir
* (optional) The path of a directory which should be copied into the
* test project and used as the active directory.
protected function createTestProject(?string $source_dir = NULL): void {
static $called;
if (isset($called)) {
throw new \LogicException('Only one test project should be created per kernel test method!');
}
else {
$called = TRUE;
}
$this->testProjectRoot = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . 'package_manager_testing_root' . $this->databasePrefix;
if (is_dir($this->testProjectRoot)) {
$this->fileSystem->remove($this->testProjectRoot);
}
$this->fileSystem->mkdir($this->testProjectRoot);
// Create the active directory and copy its contents from a fixture.
$active_dir = $this->testProjectRoot . DIRECTORY_SEPARATOR . 'active';
$this->assertTrue(mkdir($active_dir));
static::copyFixtureFilesTo($source_dir ?? __DIR__ . '/../../fixtures/fake_site', $active_dir);
// Removing 'vfs://root/' from site path set in
// \Drupal\KernelTests\KernelTestBase::setUpFilesystem as we don't use vfs.
$test_site_path = str_replace('vfs://root/', '', $this->siteDirectory);
// Copy directory structure from vfs site directory to our site directory.
$this->fileSystem->mirror($this->siteDirectory, $active_dir . DIRECTORY_SEPARATOR . $test_site_path);
// Override siteDirectory to point to root/active/... instead of root/... .
$this->siteDirectory = $active_dir . DIRECTORY_SEPARATOR . $test_site_path;
// Override KernelTestBase::setUpFilesystem's Settings object.
$settings = Settings::getInstance() ? Settings::getAll() : [];
$settings['file_public_path'] = $this->siteDirectory . '/files';
$settings['config_sync_directory'] = $this->siteDirectory . '/files/config/sync';
new Settings($settings);

omkar podey
committed
// Create a stage root directory alongside the active directory.
$staging_root = $this->testProjectRoot . DIRECTORY_SEPARATOR . 'stage';
$this->assertTrue(mkdir($staging_root));
// Ensure the path locator points to the test project. We assume that is its
// own web root and the vendor directory is at its top level.
/** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */
$path_locator = $this->container->get(PathLocator::class);
$path_locator->setPaths($active_dir, $active_dir . '/vendor', '', $staging_root);

Adam G-H
committed
// This validator will persist through container rebuilds.

Adam G-H
committed
// @see ::register()
$validator = new TestDiskSpaceValidator($path_locator);
// By default, the validator should report that the root, vendor, and
// temporary directories have basically infinite free space.
$validator->freeSpace = [
$path_locator->getProjectRoot() => PHP_INT_MAX,
$path_locator->getVendorDirectory() => PHP_INT_MAX,
$validator->temporaryDirectory() => PHP_INT_MAX,
];
$this->container->set('package_manager.validator.disk_space', $validator);
}
/**
* Sets the current (running) version of core, as known to the Update module.
*
* @todo Remove this function with use of the trait from the Update module in
* https://drupal.org/i/3348234.
*
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
* @param string $version
* The current version of core.
*/
protected function setCoreVersion(string $version): void {
$this->config('update_test.settings')
->set('system_info.#all.version', $version)
->save();
}
/**
* Sets the release metadata file to use when fetching available updates.
*
* @param string[] $files
* The paths of the XML metadata files to use, keyed by project name.
*/
protected function setReleaseMetadata(array $files): void {
$responses = [];
foreach ($files as $project => $file) {
$metadata = Utils::tryFopen($file, 'r');
$responses["/release-history/$project/current"] = new Response(200, [], Utils::streamFor($metadata));
}
$callable = function (RequestInterface $request) use ($responses): Response {
return $responses[$request->getUri()->getPath()] ?? new Response(404);
};
// The mock handler's queue consist of same callable as many times as the
// number of requests we expect to be made for update XML because it will
// retrieve one item off the queue for each request.
// @see \GuzzleHttp\Handler\MockHandler::__invoke()
$handler = new MockHandler(array_fill(0, 100, $callable));
$this->client = new Client([
'handler' => HandlerStack::create($handler),
]);
$this->container->set('http_client', $this->client);
}

Kunal Sachdev
committed
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
/**
* Adds an event listener on an event for testing purposes.
*
* @param callable $listener
* The listener to add.
* @param string $event_class
* (optional) The event to listen to. Defaults to PreApplyEvent.
* @param int $priority
* (optional) The priority. Defaults to PHP_INT_MAX.
*/
protected function addEventTestListener(callable $listener, string $event_class = PreApplyEvent::class, int $priority = PHP_INT_MAX): void {
$this->container->get('event_dispatcher')
->addListener($event_class, $listener, $priority);
}
/**
* Asserts event propagation is stopped by a certain event subscriber.
*
* @param string $event_class
* The event during which propagation is expected to stop.
* @param callable $expected_propagation_stopper
* The event subscriber (which subscribes to the given event class) which is
* expected to stop propagation. This event subscriber must have been
* registered by one of the installed Drupal module.
*/
protected function assertEventPropagationStopped(string $event_class, callable $expected_propagation_stopper): void {
$priority = $this->container->get('event_dispatcher')->getListenerPriority($event_class, $expected_propagation_stopper);
// Ensure the event subscriber was actually a listener for the event.
$this->assertIsInt($priority);
// Add a listener with a priority that is 1 less than priority of the
// event subscriber. This listener would be called after
// $expected_propagation_stopper if the event propagation was not stopped
// and cause the test to fail.
$this->addEventTestListener(function () use ($event_class): void {
$this->fail('Event propagation should have been stopped during ' . $event_class . '.');
}, $event_class, $priority - 1);
}

Ted Bowman
committed
/**
* {@inheritdoc}
*/
protected function tearDown(): void {
// Delete the test project root, which contains the active directory and
// the stage directory. First, make it writable in case any permissions were
// changed during the test.
if ($this->testProjectRoot) {
$this->fileSystem->chmod($this->testProjectRoot, 0777, 0000, TRUE);
$this->fileSystem->remove($this->testProjectRoot);
}

Ted Bowman
committed
StageFixtureManipulator::handleTearDown();
// Ensure no warnings (or worse) were logged by Package Manager.
$this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::EMERGENCY), 'Package Manager logged emergencies.');
$this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::ALERT), 'Package Manager logged alerts.');
$this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::CRITICAL), 'Package Manager logged critical errors.');
$this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::ERROR), 'Package Manager logged errors.');
$this->assertFalse($this->failureLogger->hasRecords(RfcLogLevel::WARNING), 'Package Manager logged warnings.');

omkar podey
committed
parent::tearDown();

Ted Bowman
committed
}
* Asserts that a StageEventException has a particular set of results.
* @param array $expected_results
* The expected results.
* @param \Drupal\package_manager\Exception\StageEventException $exception
* The exception.
protected function assertExpectedResultsFromException(array $expected_results, StageEventException $exception): void {
$event = $exception->event;
$this->assertInstanceOf(PreOperationStageEvent::class, $event);
$this->assertValidationResultsEqual($expected_results, $event->getResults());
* Creates a StageEventException from an array of validation results.
* @param \Drupal\package_manager\ValidationResult[] $expected_results
* The validation results. Note that only errors will be added to the event;
* warnings will be ignored.
* @param string $event_class
* (optional) The event which raised the exception. Defaults to
* PreCreateEvent.

Kunal Sachdev
committed
* @param \Drupal\package_manager\StageBase $stage
* (optional) The stage which caused the exception.
* @return \Drupal\package_manager\Exception\StageEventException
* An exception with the given validation results.

Kunal Sachdev
committed
protected function createStageEventExceptionFromResults(array $expected_results, string $event_class = PreCreateEvent::class, StageBase $stage = NULL): StageEventException {
$event = new $event_class($stage ?? $this->createStage(), []);

Adam G-H
committed
foreach ($expected_results as $result) {
if ($result->severity === SystemManager::REQUIREMENT_ERROR) {
$event->addError($result->messages, $result->summary);
}

Adam G-H
committed
}
return new StageEventException($event);

Adam G-H
committed
}
/**
* Defines a stage specifically for testing purposes.
*/

Kunal Sachdev
committed
class TestStage extends StageBase {
/**
* {@inheritdoc}
*
* TRICKY: without this, any failed ::assertStatusCheckResults()
* will fail, because PHPUnit will want to serialize all arguments in the call
* stack.
*
* @see https://www.drupal.org/project/automatic_updates/issues/3312619#comment-14801308
*/
public function __sleep(): array {
return [];
}

Adam G-H
committed
}
487
488
489
490
491
492
493
494
495
496
497
498
499
500
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
530
/**
* A test version of the disk space validator to bypass system-level functions.
*/
class TestDiskSpaceValidator extends DiskSpaceValidator {
/**
* Whether the root and vendor directories are on the same logical disk.
*
* @var bool
*/
public $sharedDisk = TRUE;
/**
* The amount of free space, keyed by path.
*
* @var float[]
*/
public $freeSpace = [];
/**
* {@inheritdoc}
*/
protected function stat(string $path): array {
return [
'dev' => $this->sharedDisk ? 'disk' : uniqid(),
];
}
/**
* {@inheritdoc}
*/
protected function freeSpace(string $path): float {
return $this->freeSpace[$path];
}
/**
* {@inheritdoc}
*/
public function temporaryDirectory(): string {
return 'temp';
}
}