diff --git a/automatic_updates_extensions/automatic_updates_extensions.services.yml b/automatic_updates_extensions/automatic_updates_extensions.services.yml index a91eeee366a3e935f34d2cbde316f3731d8abaf0..acac491c30d1580bf01acea0732407717f8331ea 100644 --- a/automatic_updates_extensions/automatic_updates_extensions.services.yml +++ b/automatic_updates_extensions/automatic_updates_extensions.services.yml @@ -6,6 +6,9 @@ services: Drupal\automatic_updates_extensions\Validator\UpdateReleaseValidator: tags: - { name: event_subscriber } + Drupal\automatic_updates_extensions\Validator\ForbidCoreChangesValidator: + tags: + - { name: event_subscriber } Drupal\automatic_updates_extensions\Validator\RequestedUpdateValidator: tags: - { name: event_subscriber } diff --git a/automatic_updates_extensions/src/Validator/ForbidCoreChangesValidator.php b/automatic_updates_extensions/src/Validator/ForbidCoreChangesValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..6be66670c2725392edfcb3ffdf61a7343d803b14 --- /dev/null +++ b/automatic_updates_extensions/src/Validator/ForbidCoreChangesValidator.php @@ -0,0 +1,110 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\automatic_updates_extensions\Validator; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Url; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Validates that no changes were made to Drupal Core packages. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +final class ForbidCoreChangesValidator implements EventSubscriberInterface { + + use StringTranslationTrait; + + public function __construct( + private readonly PathLocator $pathLocator, + private readonly ComposerInspector $composerInspector, + ) {} + + /** + * Validates the staged packages. + * + * @param \Drupal\package_manager\Event\StatusCheckEvent|\Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function validateStagedCorePackages(StatusCheckEvent|PreApplyEvent $event): void { + $stage = $event->stage; + // We only want to do this check if the stage belongs to Automatic Updates + // Extensions. + if ($stage->getType() !== 'automatic_updates_extensions:attended' || !$stage->stageDirectoryExists()) { + return; + } + $active_core_packages = $this->getInstalledCorePackages($this->pathLocator->getProjectRoot()); + $stage_core_packages = $this->getInstalledCorePackages($stage->getStageDirectory()); + + $new_packages = $stage_core_packages->getPackagesNotIn($active_core_packages); + $removed_packages = $active_core_packages->getPackagesNotIn($stage_core_packages); + $changed_packages = $active_core_packages->getPackagesWithDifferentVersionsIn($stage_core_packages); + + $error_messages = []; + foreach ($new_packages as $new_package) { + $error_messages[] = $this->t("'@name' installed.", ['@name' => $new_package->name]); + } + foreach ($removed_packages as $removed_package) { + $error_messages[] = $this->t("'@name' removed.", ['@name' => $removed_package->name]); + } + foreach ($changed_packages as $name => $updated_package) { + $error_messages[] = $this->t( + "'@name' version changed from @active_version to @staged_version.", + [ + '@name' => $updated_package->name, + '@staged_version' => $stage_core_packages[$name]->version, + '@active_version' => $updated_package->version, + ] + ); + + } + if ($error_messages) { + $event->addError($error_messages, $this->t( + 'Updating Drupal Core while updating extensions is currently not supported. Use <a href=":url">this form</a> to update Drupal core. The following changes were made to the Drupal core packages:', + [':url' => Url::fromRoute('update.report_update')->toString()] + )); + } + } + + /** + * Gets all the installed core packages for a given project root. + * + * This method differs from + * \Drupal\package_manager\ComposerInspector::getInstalledPackagesList in that + * it ensures that the 'drupal/core' is included in the list if present. + * + * @param string $composer_root + * The path to the composer root. + * + * @return \Drupal\package_manager\InstalledPackagesList + * The installed core packages. + */ + private function getInstalledCorePackages(string $composer_root): InstalledPackagesList { + $installed_package_list = $this->composerInspector->getInstalledPackagesList($composer_root); + $core_packages = $installed_package_list->getCorePackages(); + if (isset($installed_package_list['drupal/core']) && !isset($core_packages['drupal/core'])) { + $core_packages = new InstalledPackagesList(array_merge($core_packages->getArrayCopy(), ['drupal/core' => $installed_package_list['drupal/core']])); + } + return $core_packages; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + $events[StatusCheckEvent::class][] = ['validateStagedCorePackages']; + $events[PreApplyEvent::class][] = ['validateStagedCorePackages']; + return $events; + } + +} diff --git a/automatic_updates_extensions/tests/src/Kernel/Validator/ForbidCoreChangesValidatorTest.php b/automatic_updates_extensions/tests/src/Kernel/Validator/ForbidCoreChangesValidatorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a6b3f7027206fe1ad200ce52e091381b936bc956 --- /dev/null +++ b/automatic_updates_extensions/tests/src/Kernel/Validator/ForbidCoreChangesValidatorTest.php @@ -0,0 +1,133 @@ +<?php + +namespace Drupal\Tests\automatic_updates_extensions\Kernel\Validator; + +use Drupal\automatic_updates_extensions\ExtensionUpdateStage; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\automatic_updates_extensions\Kernel\AutomaticUpdatesExtensionsKernelTestBase; + +/** + * @coversDefaultClass \Drupal\automatic_updates_extensions\Validator\ForbidCoreChangesValidator + * @group automatic_updates_extensions + * @internal + */ +class ForbidCoreChangesValidatorTest extends AutomaticUpdatesExtensionsKernelTestBase { + + /** + * Tests error messages if requested updates were not staged. + * + * @param array $staged_versions + * An array of the staged versions where the keys are the package names and + * the values are the package versions or NULL if the package should be + * removed in the stage. + * @param string[][] $new_packages + * An array of the new packages to add to the stage. + * @param ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerTestErrorMessage + */ + public function testErrorMessages(array $staged_versions, array $new_packages, array $expected_results): void { + $this->setReleaseMetadata([ + 'semver_test' => __DIR__ . '/../../../fixtures/release-history/semver_test.1.1.xml', + 'drupal' => __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml', + ]); + $this->getStageFixtureManipulator()->addPackage([ + 'name' => 'drupal/non-core', + 'version' => '1.0.0', + 'type' => 'package', + ]); + + foreach ($staged_versions as $package => $version) { + if ($version === NULL) { + $this->getStageFixtureManipulator()->removePackage($package); + continue; + } + $this->getStageFixtureManipulator()->setVersion($package, $version); + } + foreach ($new_packages as $package) { + $this->getStageFixtureManipulator()->addPackage($package); + } + $this->getStageFixtureManipulator()->setVersion('drupal/semver_test', '8.1.1'); + + $stage = $this->container->get(ExtensionUpdateStage::class); + $stage->begin([ + 'semver_test' => '8.1.1', + ]); + $stage->stage(); + $this->assertStatusCheckResults($expected_results, $stage); + try { + $stage->apply(); + $this->fail('Expecting an exception.'); + } + catch (StageEventException $exception) { + $this->assertExpectedResultsFromException($expected_results, $exception); + } + } + + /** + * Data provider for testErrorMessage(). + * + * @return mixed[] + * The test cases. + */ + public function providerTestErrorMessage(): array { + $summary = t('Updating Drupal Core while updating extensions is currently not supported. Use <a href="/admin/reports/updates/update">this form</a> to update Drupal core. The following changes were made to the Drupal core packages:'); + return [ + 'drupal/core updated, non-core updated' => [ + [ + 'drupal/core' => '9.8.1', + 'drupal/non-core' => '1.0.1', + ], + [], + [ValidationResult::createError([t("'drupal/core' version changed from 9.8.0 to 9.8.1.")], $summary)], + ], + 'drupal/core-recommended and drupal/core updated, non-core package installed' => [ + [ + 'drupal/core-recommended' => '9.8.1', + 'drupal/core' => '9.8.1', + ], + [ + [ + 'name' => 'other-org/other-package', + 'type' => 'package', + ], + ], + [ + ValidationResult::createError( + [ + t("'drupal/core-recommended' version changed from 9.8.0 to 9.8.1."), + t("'drupal/core' version changed from 9.8.0 to 9.8.1."), + ], + $summary + ), + ], + ], + 'drupal/core-recommended removed, drupal/core updated, drupal/core-composer-scaffold installed, non-core package removed' => [ + [ + 'drupal/core-recommended' => NULL, + 'drupal/core' => '9.8.1', + 'drupal/non-core' => NULL, + ], + [ + [ + 'name' => 'drupal/core-composer-scaffold', + 'type' => 'package', + ], + ], + [ + ValidationResult::createError( + [ + t("'drupal/core-composer-scaffold' installed."), + t("'drupal/core-recommended' removed."), + t("'drupal/core' version changed from 9.8.0 to 9.8.1."), + ], + $summary + ), + ], + ], + ]; + } + +}