From 92b7cbf4f4590894868f2fd4b3d4697f0770ac5f Mon Sep 17 00:00:00 2001 From: "Theresa.Grannum" <theresa.grannum@3688861.no-reply.drupal.org> Date: Fri, 15 Apr 2022 00:43:38 +0000 Subject: [PATCH] Issue #3273812 by Theresa.Grannum: AU Extensions: Create a validator to ensure updated packages are secure and supported --- .../automatic_updates_extensions.services.yml | 4 + .../src/Validator/UpdateReleaseValidator.php | 67 ++++++++ .../fixtures/release-history/alpha.1.1.0.xml | 40 +++++ .../release-history/semver_test.1.1.xml | 1 - .../tests/src/Build/ModuleUpdateTest.php | 21 ++- ...tomaticUpdatesExtensionsKernelTestBase.php | 154 ++++++++++++++++++ .../tests/src/Kernel/ExtensionUpdaterTest.php | 1 + .../Validator/UpdateReleaseValidatorTest.php | 66 ++++++++ .../tests/fixtures/alpha/1.0.0/alpha.info.yml | 3 + .../tests/fixtures/alpha/1.1.0/alpha.info.yml | 3 + 10 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 automatic_updates_extensions/src/Validator/UpdateReleaseValidator.php create mode 100644 automatic_updates_extensions/tests/fixtures/release-history/alpha.1.1.0.xml create mode 100644 automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php create mode 100644 automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php create mode 100644 package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml create mode 100644 package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml diff --git a/automatic_updates_extensions/automatic_updates_extensions.services.yml b/automatic_updates_extensions/automatic_updates_extensions.services.yml index ba5bd80723..dea4d1ff45 100644 --- a/automatic_updates_extensions/automatic_updates_extensions.services.yml +++ b/automatic_updates_extensions/automatic_updates_extensions.services.yml @@ -11,3 +11,7 @@ services: - '@event_dispatcher' - '@tempstore.shared' - '@datetime.time' + automatic_updates_extensions.validator.target_release: + class: Drupal\automatic_updates_extensions\Validator\UpdateReleaseValidator + tags: + - { name: event_subscriber } diff --git a/automatic_updates_extensions/src/Validator/UpdateReleaseValidator.php b/automatic_updates_extensions/src/Validator/UpdateReleaseValidator.php new file mode 100644 index 0000000000..994ac31e6a --- /dev/null +++ b/automatic_updates_extensions/src/Validator/UpdateReleaseValidator.php @@ -0,0 +1,67 @@ +<?php + +namespace Drupal\automatic_updates_extensions\Validator; + +use Drupal\automatic_updates\ProjectInfo; +use Drupal\automatic_updates_extensions\ExtensionUpdater; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\Event\PreCreateEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that updated projects are secure and supported. + */ +class UpdateReleaseValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + /** + * Checks that the update projects are secure and supported. + * + * @param \Drupal\package_manager\Event\PreCreateEvent $event + * The event object. + */ + public function checkRelease(PreCreateEvent $event): void { + $stage = $event->getStage(); + // This check only works with Automatic Updates Extensions. + if (!$stage instanceof ExtensionUpdater) { + return; + } + + $all_versions = $stage->getPackageVersions(); + $messages = []; + foreach (['production', 'dev'] as $package_type) { + foreach ($all_versions[$package_type] as $package_name => $version) { + $package_parts = explode('/', $package_name); + $project_name = $package_parts[1]; + // If the version isn't in the list of installable releases, then it + // isn't secure and supported and the user should receive an error. + $releases = (new ProjectInfo($project_name))->getInstallableReleases(); + if (empty($releases) || !array_key_exists($version, $releases)) { + $messages[] = $this->t('Project @project_name to version @version', [ + '@project_name' => $project_name, + '@version' => $version, + ]); + } + } + } + if ($messages) { + $summary = $this->formatPlural( + count($messages), + 'Cannot update because the following project version is not in the list of installable releases.', + 'Cannot update because the following project versions are not in the list of installable releases.' + ); + $event->addError($messages, $summary); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + return [ + PreCreateEvent::class => 'checkRelease', + ]; + } + +} diff --git a/automatic_updates_extensions/tests/fixtures/release-history/alpha.1.1.0.xml b/automatic_updates_extensions/tests/fixtures/release-history/alpha.1.1.0.xml new file mode 100644 index 0000000000..1bcdbbad80 --- /dev/null +++ b/automatic_updates_extensions/tests/fixtures/release-history/alpha.1.1.0.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Alpha</title> +<short_name>alpha</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>1.1.,1.0.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/alpha</link> + <terms> + <term><name>Projects</name><value>Alpha project</value></term> + </terms> +<releases> + <release> + <name>Alpha 1.1.0</name> + <version>1.1.0</version> + <tag>1.1.0</tag> + <status>published</status> + <release_link>http://example.com/alpha-1-1-0-release</release_link> + <download_link>http://example.com/alpha-1-1-0.tar.gz</download_link> + <date>1584195300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> + <release> + <name>Alpha 1.0.0</name> + <version>1.0.0</version> + <tag>1.0.0</tag> + <status>published</status> + <release_link>http://example.com/alpha-1-0-0-release</release_link> + <download_link>http://example.com/alpha-1-0-0.tar.gz</download_link> + <date>1581603300</date> + <terms> + <term><name>Release type</name><value>New features</value></term> + <term><name>Release type</name><value>Bug fixes</value></term> + </terms> + </release> +</releases> +</project> diff --git a/automatic_updates_extensions/tests/fixtures/release-history/semver_test.1.1.xml b/automatic_updates_extensions/tests/fixtures/release-history/semver_test.1.1.xml index cdb353fd42..addca0b25b 100644 --- a/automatic_updates_extensions/tests/fixtures/release-history/semver_test.1.1.xml +++ b/automatic_updates_extensions/tests/fixtures/release-history/semver_test.1.1.xml @@ -11,7 +11,6 @@ </terms> <releases> <release> - <!-- This release is not in a supported branch; therefore it should not be recommended. --> <name>Semver Test 8.2.0</name> <version>8.2.0</version> <tag>8.2.0</tag> diff --git a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php index f84c55f974..16c59dcf1f 100644 --- a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php +++ b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php @@ -2,27 +2,38 @@ namespace Drupal\Tests\automatic_updates_extensions\Build; -use Drupal\Tests\package_manager\Build\TemplateProjectTestBase; +use Drupal\Tests\automatic_updates\Build\UpdateTestBase; /** * Tests updating modules in a staging area. * * @group automatic_updates_extensions */ -class ModuleUpdateTest extends TemplateProjectTestBase { +class ModuleUpdateTest extends UpdateTestBase { /** * Tests updating a module in a staging area. */ public function testApi(): void { $this->createTestProject('RecommendedProject'); + $this->setReleaseMetadata([ + 'drupal' => __DIR__ . '/../../../../tests/fixtures/release-history/drupal.9.8.1-security.xml', + 'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml', + ]); + + // Set 'version' and 'project' for the 'alpha' module to enable the Update + // to determine the update status. + $system_info = ['alpha' => ['version' => '1.0.0', 'project' => 'alpha']]; + $system_info = var_export($system_info, TRUE); + $code = <<<END +\$config['update_test.settings']['system_info'] = $system_info; +END; + $this->writeSettings($code); $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.0.0'); $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha --update-with-all-dependencies', 'project'); - $this->installQuickStart('minimal'); - $this->formLogin($this->adminUsername, $this->adminPassword); - $this->installModules(['automatic_updates_extensions_test_api']); + $this->installModules(['automatic_updates_extensions_test_api', 'alpha']); // Change both modules' upstream version. $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.1.0'); diff --git a/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php b/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php new file mode 100644 index 0000000000..58f637515d --- /dev/null +++ b/automatic_updates_extensions/tests/src/Kernel/AutomaticUpdatesExtensionsKernelTestBase.php @@ -0,0 +1,154 @@ +<?php + +namespace Drupal\Tests\automatic_updates_extensions\Kernel; + +use Drupal\automatic_updates_extensions\ExtensionUpdater; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageValidationException; +use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Psr7\Utils; +use Psr\Http\Message\RequestInterface; + +/** + * Base class for kernel tests of the Automatic Updates Extensions module. + */ +abstract class AutomaticUpdatesExtensionsKernelTestBase extends AutomaticUpdatesKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'automatic_updates_extensions', + 'automatic_updates_test_release_history', + ]; + + /** + * The client. + * + * @var \GuzzleHttp\Client + */ + protected $client; + + /** + * Asserts validation results are returned from a stage life cycle event. + * + * @param string[] $project_versions + * The project versions. + * @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. + */ + protected function assertUpdaterResults(array $project_versions, array $expected_results, string $event_class = NULL): void { + $updater = $this->createExtensionUpdater(); + + try { + $updater->begin($project_versions); + $updater->stage(); + $updater->apply(); + $updater->destroy(); + + // If we did not get an exception, ensure we didn't expect any results. + $this->assertEmpty($expected_results); + } + catch (StageValidationException $e) { + $this->assertNotEmpty($expected_results); + $this->assertValidationResultsEqual($expected_results, $e->getResults()); + // TestStage::dispatch() attaches the event object to the exception so + // that we can analyze it. + $this->assertNotEmpty($event_class); + $this->assertInstanceOf($event_class, $e->event); + } + } + + /** + * 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 setReleaseMetadataForProjects(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, count($responses), $callable)); + $this->client = new Client([ + 'handler' => HandlerStack::create($handler), + ]); + $this->container->set('http_client', $this->client); + } + + /** + * Creates an extension updater object for testing purposes. + * + * @return \Drupal\Tests\automatic_updates_extensions\Kernel\TestExtensionUpdater + * A extension updater object, with test-only modifications. + */ + protected function createExtensionUpdater(): TestExtensionUpdater { + return new TestExtensionUpdater( + $this->container->get('config.factory'), + $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'), + $this->container->get('file_system'), + $this->container->get('event_dispatcher'), + $this->container->get('tempstore.shared'), + $this->container->get('datetime.time') + ); + } + +} + +/** + * Defines a updater specifically for testing purposes. + */ +class TestExtensionUpdater extends ExtensionUpdater { + + /** + * The directory where staging areas will be created. + * + * @var string + */ + public static $stagingRoot; + + /** + * {@inheritdoc} + */ + public function getStagingRoot(): string { + return static::$stagingRoot ?: parent::getStagingRoot(); + } + + /** + * {@inheritdoc} + */ + protected function dispatch(StageEvent $event, callable $on_error = NULL): void { + try { + parent::dispatch($event, $on_error); + } + catch (StageException $e) { + // Attach the event object to the exception so that test code can verify + // that the exception was thrown when a specific event was dispatched. + $e->event = $event; + throw $e; + } + } + +} diff --git a/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php b/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php index 1305b87a31..abfd48db75 100644 --- a/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php +++ b/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php @@ -28,6 +28,7 @@ class ExtensionUpdaterTest extends AutomaticUpdatesKernelTestBase { * {@inheritdoc} */ protected function setUp(): void { + $this->disableValidators[] = 'automatic_updates_extensions.validator.target_release'; parent::setUp(); $this->installEntitySchema('user'); } diff --git a/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php b/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php new file mode 100644 index 0000000000..4c6fccfaef --- /dev/null +++ b/automatic_updates_extensions/tests/src/Kernel/Validator/UpdateReleaseValidatorTest.php @@ -0,0 +1,66 @@ +<?php + +namespace Drupal\Tests\automatic_updates_extensions\Kernel\Valdiator; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\automatic_updates_extensions\Kernel\AutomaticUpdatesExtensionsKernelTestBase; + +/** + * @coversDefaultClass \Drupal\automatic_updates_extensions\Validator\UpdateReleaseValidator + * + * @group automatic_updates_extensions + */ +class UpdateReleaseValidatorTest extends AutomaticUpdatesExtensionsKernelTestBase { + + /** + * Tests updating to a release. + * + * @param string $installed_version + * The installed version of the project. + * @param string $update_version + * The version to update to. + * @param bool $error_expected + * Whether an error is expected in the update. + * + * @dataProvider providerTestRelease + */ + public function testRelease(string $installed_version, string $update_version, bool $error_expected) { + $this->enableModules(['semver_test']); + $module_info = ['version' => $installed_version, 'project' => 'semver_test']; + $this->config('update_test.settings') + ->set("system_info.semver_test", $module_info) + ->save(); + $this->setReleaseMetadataForProjects([ + 'semver_test' => __DIR__ . '/../../../fixtures/release-history/semver_test.1.1.xml', + 'drupal' => __DIR__ . '/../../../../../tests/fixtures/release-history/drupal.9.8.2.xml', + ]); + if ($error_expected) { + $expected_results = [ + ValidationResult::createError( + ["Project semver_test to version $update_version"], + t('Cannot update because the following project version is not in the list of installable releases.') + ), + ]; + } + else { + $expected_results = []; + } + + $this->assertUpdaterResults(['semver_test' => $update_version], $expected_results, PreCreateEvent::class); + } + + /** + * Data provider for testRelease(). + * + * @return array[] + * The test cases. + */ + public function providerTestRelease() { + return [ + 'supported update' => ['8.1.0', '8.1.1', FALSE], + 'update to unsupported branch' => ['8.1.0', '8.2.0', TRUE], + ]; + } + +} diff --git a/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml b/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml new file mode 100644 index 0000000000..565d3142d8 --- /dev/null +++ b/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml @@ -0,0 +1,3 @@ +name: Alpha +type: module +core_version_requirement: ^9 diff --git a/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml b/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml new file mode 100644 index 0000000000..565d3142d8 --- /dev/null +++ b/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml @@ -0,0 +1,3 @@ +name: Alpha +type: module +core_version_requirement: ^9 -- GitLab