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 Drupal\Component\FileSystem\FileSystem as DrupalFileSystem;

Adam G-H
committed
use Drupal\Core\DependencyInjection\ContainerBuilder;
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\StatusCheckTrait;
use Drupal\package_manager\Validator\DiskSpaceValidator;

Adam G-H
committed
use Drupal\package_manager\Stage;
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
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig('package_manager');
$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();
}

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

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(

Adam G-H
committed
$this->container->get('package_manager.path_locator'),
$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('package_manager.failure_marker')

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.
*
* @return \Drupal\package_manager\Stage
* The stage that was used to collect the validation results.
*/
protected function assertResults(array $expected_results, string $event_class = NULL): Stage {
$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.

Narendra Singh Rathore
committed
protected function assertStatusCheckResults(array $expected_results, Stage $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;
}
$source_dir = $source_dir ?? __DIR__ . '/../../fixtures/fake_site';
$root = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . 'package_manager_testing_root' . $this->databasePrefix;
$fs = new Filesystem();
if (is_dir($root)) {
$fs->remove($root);
}
$fs->mkdir($root);
// Create the active directory and copy its contents from a fixture.
$active_dir = $root . DIRECTORY_SEPARATOR . 'active';
$this->assertTrue(mkdir($active_dir));
static::copyFixtureFilesTo($source_dir, $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.
(new 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 = $root . 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('package_manager.path_locator');
$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);
}
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
307
308
309
310
311
312
313
314
315
316
317
318
319
320
/**
* Sets the current (running) version of core, as known to the Update module.
*
* @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
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
/**
* 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 {
StageFixtureManipulator::handleTearDown();
}
* 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.
* @param \Drupal\package_manager\Stage $stage
* (optional) The stage which caused the exception.
* @return \Drupal\package_manager\Exception\StageEventException
* An exception with the given validation results.
protected function createStageEventExceptionFromResults(array $expected_results, string $event_class = PreCreateEvent::class, Stage $stage = NULL): StageEventException {
$event = new $event_class($stage ?? $this->createStage(), []);

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

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

Adam G-H
committed
}
/**
* Defines a stage specifically for testing purposes.
*/
class TestStage extends Stage {
/**
* {@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
}
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
/**
* 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';
}
}