diff options
author | Alex Pott <alex.a.pott@googlemail.com> | 2024-10-25 11:05:53 +0100 |
---|---|---|
committer | Alex Pott <alex.a.pott@googlemail.com> | 2024-10-25 11:05:53 +0100 |
commit | 29756947e9132bf1cf2d51fc5b57481aafc6b0c3 (patch) | |
tree | 1f59341f49b07ee345add71bbafccfdc844b5096 /core/modules/package_manager/tests | |
parent | c836e0a0994635b8822e38639e2a2ca5dc514aa6 (diff) | |
download | drupal-29756947e9132bf1cf2d51fc5b57481aafc6b0c3.tar.gz drupal-29756947e9132bf1cf2d51fc5b57481aafc6b0c3.zip |
Issue #3346707 by tedbow, phenaproxima, alexpott, catch, wim leers, dww, effulgentsia, gábor hojtsy, drumm, grasmash, chrisfromredfin, fizcs3, cola, capysara, diegors, daisyleroy, abhishek_gupta1, bnjmnm, quietone, lauriii, poker10, xjm, anish.a, ajits, traviscarden, heddn, Idoni, srishtiiee, siramsay, shabbir, rocketeerbkw, Schnitzel, s_leu, Theresa.Grannum, yash.rode, wiifm, wendyZ, tim.plunkett, Webbeh, rkoller, Ranjit1032002, kunal.sachdev, kjankowski, jayesh.d, immaculatexavier, Ishani Patel, leksat, lhridley, percoction, rahul_, p.ayekumi, omkar.podey, narendra.rajwar27, narendrar: Add Alpha level Experimental Package Manager module
Diffstat (limited to 'core/modules/package_manager/tests')
176 files changed, 16235 insertions, 0 deletions
diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/README.md b/core/modules/package_manager/tests/fixtures/build_test_projects/README.md new file mode 100644 index 00000000000..40e2077edb4 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/README.md @@ -0,0 +1,11 @@ +# Why do we need the `updated_module` fixtures? +Because there is a need to thoroughly test the updating of a module. See `\Drupal\Tests\package_manager\Build\PackageUpdateTest`. + +This requires 2 versions (`1.0.0` and `1.1.0`) of the same module (`updated_module`), each with a different code bases. + +The test updates from one version to the next, and verifies the updated module's code base is actually used after the update: it verifies the updated logic of version of `\Drupal\updated_module\PostApplySubscriber` is being executed. + +`\Drupal\fixture_manipulator\FixtureManipulator` cannot manipulate code nor does it modify the file system: it only creates a "skeleton" extension. (See `\Drupal\fixture_manipulator\FixtureManipulator::addProjectAtPath()`.) + +# Why do we need the `alpha` fixtures? +To be able to test that `php-tuf/composer-stager` indeed only updates the package for which an update was requested (even though more updates are available), no fixture manipulation is allowed to occur. This requires updating a `path` composer package repository to first serve contain one version of a package, and then another. That is what these fixtures are used for. diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/alpha.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/alpha.info.yml.hide new file mode 100644 index 00000000000..3029b2a6d90 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/alpha.info.yml.hide @@ -0,0 +1,4 @@ +name: Alpha +type: module +core_version_requirement: ^9.7 || ^10 +project: alpha diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/composer.json new file mode 100644 index 00000000000..35db7d858c4 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.0.0/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/alpha", + "type": "drupal-module", + "version": "1.0.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/alpha.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/alpha.info.yml.hide new file mode 100644 index 00000000000..3029b2a6d90 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/alpha.info.yml.hide @@ -0,0 +1,4 @@ +name: Alpha +type: module +core_version_requirement: ^9.7 || ^10 +project: alpha diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/composer.json new file mode 100644 index 00000000000..f21a204a76d --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/alpha/1.1.0/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/alpha", + "type": "drupal-module", + "version": "1.1.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/composer.json new file mode 100644 index 00000000000..44b9518c5b5 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/main_module", + "type": "drupal-module", + "version": "1.0.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module.info.yml.hide new file mode 100644 index 00000000000..1852bfe5947 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module.info.yml.hide @@ -0,0 +1,4 @@ +name: Main module +type: module +core_version_requirement: ^9 || ^10 +project: main_module diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module_submodule/main_module_submodule.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module_submodule/main_module_submodule.info.yml.hide new file mode 100644 index 00000000000..1383bc227ae --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/main_module/main_module_submodule/main_module_submodule.info.yml.hide @@ -0,0 +1,4 @@ +name: Main Module Submodule +type: module +core_version_requirement: ^9 || ^10 +project: main_module diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/composer.json new file mode 100644 index 00000000000..777cd741d24 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/updated_module", + "type": "drupal-module", + "version": "1.0.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.info.yml.hide new file mode 100644 index 00000000000..5d31bbdb5a8 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.info.yml.hide @@ -0,0 +1,5 @@ +name: 'Updated module' +description: 'A module which will change during an update, to ensure that the changes are picked up.' +type: module +package: Testing +project: updated_module diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.module b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.module new file mode 100644 index 00000000000..0f90596c5ff --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.0.0/updated_module.module @@ -0,0 +1,18 @@ +<?php + +/** + * @file + * Contains global functions for testing updates to a .module file. + */ + +/** + * Page controller that says hello. + * + * @return string[] + * A renderable array of the page content. + */ +function updated_module_hello(): array { + return [ + '#markup' => 'Hello!', + ]; +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/composer.json b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/composer.json new file mode 100644 index 00000000000..6f997dad4cf --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/composer.json @@ -0,0 +1,5 @@ +{ + "name": "drupal/updated_module", + "type": "drupal-module", + "version": "1.1.0" +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/src/PostApplySubscriber.php b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/src/PostApplySubscriber.php new file mode 100644 index 00000000000..351933e341c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/src/PostApplySubscriber.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\updated_module; + +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Writes a file after staged changes are applied to the active directory. + * + * This event subscriber doesn't exist in version 1.0.0 of this module, so we + * use it to test that new event subscribers are picked up after staged changes + * have been applied. + */ +class PostApplySubscriber implements EventSubscriberInterface { + + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + private $pathLocator; + + /** + * Constructs a PostApplySubscriber. + * + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + */ + public function __construct(PathLocator $path_locator) { + $this->pathLocator = $path_locator; + } + + /** + * Writes a file when staged changes are applied to the active directory. + */ + public function postApply(): void { + $dir = $this->pathLocator->getProjectRoot(); + file_put_contents("$dir/bravo.txt", 'Bravo!'); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PostApplyEvent::class => 'postApply', + ]; + } + +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.info.yml.hide b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.info.yml.hide new file mode 100644 index 00000000000..5d31bbdb5a8 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.info.yml.hide @@ -0,0 +1,5 @@ +name: 'Updated module' +description: 'A module which will change during an update, to ensure that the changes are picked up.' +type: module +package: Testing +project: updated_module diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.module b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.module new file mode 100644 index 00000000000..0f90596c5ff --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.module @@ -0,0 +1,18 @@ +<?php + +/** + * @file + * Contains global functions for testing updates to a .module file. + */ + +/** + * Page controller that says hello. + * + * @return string[] + * A renderable array of the page content. + */ +function updated_module_hello(): array { + return [ + '#markup' => 'Hello!', + ]; +} diff --git a/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.services.yml b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.services.yml new file mode 100644 index 00000000000..11bbfb9e954 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/build_test_projects/updated_module/1.1.0/updated_module.services.yml @@ -0,0 +1,7 @@ +services: + updated_module.post_apply_subscriber: + class: Drupal\updated_module\PostApplySubscriber + arguments: + - '@Drupal\package_manager\PathLocator' + tags: + - { name: event_subscriber } diff --git a/core/modules/package_manager/tests/fixtures/db_update.php b/core/modules/package_manager/tests/fixtures/db_update.php new file mode 100644 index 00000000000..7adacfb2e2e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/db_update.php @@ -0,0 +1,14 @@ +<?php + +/** + * @file + * Contains a fake database update function for testing. + */ + +/** + * Here is a fake update hook. + * + * The schema version is the maximum possible value for a 32-bit integer. + */ +function package_manager_update_2147483647() { +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/.gitignore b/core/modules/package_manager/tests/fixtures/fake_site/.gitignore new file mode 100644 index 00000000000..e11552b41d4 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/.gitignore @@ -0,0 +1 @@ +# This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/README.md b/core/modules/package_manager/tests/fixtures/fake_site/README.md new file mode 100644 index 00000000000..9c9076f5b9f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/README.md @@ -0,0 +1,19 @@ +This directory is used as the basis for quasi-functional tests of Package Manager based on `\Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase`. It provides a bare-bones simulation of a real Drupal site layout, including: + +* A `.git` directory and `.gitignore` file +* A Drupal core directory with npm modules installed +* An `example` contrib module with its own `.git` directory and npm modules +* A directory in which to store private files (`private`) +* A default site directory with site-specific config files, as well as default versions of them +* A "real" site directory (`example.com`), with a public `files` directory, site-specific config files, and a SQLite database +* A `simpletest` directory containing artifacts from automated tests +* A `vendor` directory to contain installed Composer dependencies +* `composer.json` and `composer.lock` files + +Tests which use this mock site will clone it into a temporary location, then run real Composer commands in it, along with other Package Manager operations, and make assertions about the results. It's important to understand that this mock site is not at all bootable or usable as a real Drupal site. But as far as Package Manager and Composer are concerned, it IS a completely valid project that can go through all phases of the stage life cycle. + +The files named `ignore.txt` are named that way because Package Manager should ALWAYS ignore them when creating a staged copy of this mock site -- that is, they should never be copied into the stage directory, or removed from their original place, by Package Manager. + +The `.git` directories are named `_git` because we cannot commit `.git` directories to our git repository. When a test clones this mock site, these directories are automatically renamed to `.git` in the copy. + +This fixture can be re-created at any time by running, from the repository root, `php scripts/PackageManagerFixtureCreator.php`. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt new file mode 100644 index 00000000000..b70fb51506c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt @@ -0,0 +1,4 @@ +This file should never be staged. + +The parent directory will be renamed to .git. +@see \Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase::createTestProject() diff --git a/core/modules/package_manager/tests/fixtures/fake_site/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/composer.json new file mode 100644 index 00000000000..59927347a36 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/composer.json @@ -0,0 +1,47 @@ +{ + "name": "fake/site", + "description": "bull shit", + "version": "1.2.4", + "require": { + "drupal/core-recommended": "9.8.0", + "drupal/core": "9.8.0" + }, + "require-dev": { + "drupal/core-dev": "^9" + }, + "extra": { + "boo": "boo boo", + "foo": { + "dev": "2.x-dev" + }, + "foo-bar": true, + "boo-far": { + "foo": 1.23, + "bar": 134, + "foo-bar": null + }, + "baz": null, + "installer-paths": { + "modules/contrib/{$name}": ["type:drupal-module"], + "profiles/contrib/{$name}": ["type:drupal-profile"], + "themes/contrib/{$name}": ["type:drupal-theme"] + } + }, + "repositories": { + "fake-packages": { + "type": "composer", + "url": "./" + }, + "custom-package": { + "type": "path", + "url": "custom/package" + }, + "packagist.org": false + }, + "minimum-stability": "stable", + "config": { + "allow-plugins": { + "drupal/core-composer-scaffold": false + } + } +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/composer.lock b/core/modules/package_manager/tests/fixtures/fake_site/composer.lock new file mode 100644 index 00000000000..fcf06e98c78 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/composer.lock @@ -0,0 +1,88 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "e14b8d5fb12bac6df9c78c41c977e9e5", + "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core", + "reference": "31fd2270701526555acae45a3601c777e35508d5" + }, + "type": "drupal-core", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + }, + "description": "A fake version of drupal/core" + }, + { + "name": "drupal/core-recommended", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-recommended", + "reference": "112e4f7cfe8312457cd0eb58dcbffebc148850d8" + }, + "type": "project", + "description": "A fake version of drupal/core-recommended" + } + ], + "packages-dev": [ + { + "name": "drupal/core-dev", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-dev", + "reference": "b99a99a11ff2779b5e4c5787dc43575382a3548c" + }, + "type": "package", + "description": "A fake version of drupal/core-dev" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.6.0" +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/custom/package/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/custom/package/composer.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/custom/package/composer.json @@ -0,0 +1 @@ +{} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt new file mode 100644 index 00000000000..b70fb51506c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt @@ -0,0 +1,4 @@ +This file should never be staged. + +The parent directory will be renamed to .git. +@see \Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase::createTestProject() diff --git a/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml new file mode 100644 index 00000000000..046fc058cf8 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml @@ -0,0 +1,3 @@ +# This file should be staged. +name: Example +type: module diff --git a/core/modules/package_manager/tests/fixtures/fake_site/packages.json b/core/modules/package_manager/tests/fixtures/fake_site/packages.json new file mode 100644 index 00000000000..6bcbca9045e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/packages.json @@ -0,0 +1,101 @@ +{ + "packages": { + "drupal/core-recommended": { + "9.8.0": { + "name": "drupal/core-recommended", + "description": "A fake version of drupal/core-recommended", + "type": "project", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-recommended", + "reference": "112e4f7cfe8312457cd0eb58dcbffebc148850d8" + } + } + }, + "drupal/core-dev": { + "9.8.0": { + "name": "drupal/core-dev", + "description": "A fake version of drupal/core-dev", + "type": "package", + "version": "9.8.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-dev", + "reference": "b99a99a11ff2779b5e4c5787dc43575382a3548c" + } + } + }, + "drupal/core": { + "9.8.0": { + "name": "drupal/core", + "type": "drupal-core", + "description": "A fake version of drupal/core", + "version": "9.8.0", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + }, + "dist": { + "type": "path", + "url": "../path_repos/drupal--core", + "reference": "31fd2270701526555acae45a3601c777e35508d5" + } + } + }, + "cweagans/composer-patches": { + "1.7.333": { + "name": "cweagans/composer-patches", + "description": "A fake version of cweagans/composer-patches", + "type": "composer-plugin", + "version": "1.7.333", + "extra": { + "class": "\\cweagans\\Fake\\ComposerPatches" + }, + "dist": { + "type": "path", + "url": "../path_repos/cweagans--composer-patches" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "autoload": { + "psr-4": { + "cweagans\\Fake\\": "src" + } + } + } + } + } +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/private/exclude.txt b/core/modules/package_manager/tests/fixtures/fake_site/private/exclude.txt new file mode 100644 index 00000000000..08874eba8bb --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/private/exclude.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml new file mode 100644 index 00000000000..95dde1725d4 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml @@ -0,0 +1,2 @@ +# This file should be staged because it's scaffolded into place by Drupal core. +services: {} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php new file mode 100644 index 00000000000..0d23e840069 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should be staged because it's scaffolded into place by Drupal core. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml new file mode 100644 index 00000000000..cbc4434e8f2 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml @@ -0,0 +1,2 @@ +# This file should never be staged. +must_not_be: 'empty' diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php new file mode 100644 index 00000000000..15b43d28125 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php new file mode 100644 index 00000000000..15b43d28125 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt new file mode 100644 index 00000000000..0087269e33e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt @@ -0,0 +1 @@ +This file should be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite new file mode 100644 index 00000000000..08874eba8bb --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm new file mode 100644 index 00000000000..08874eba8bb --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal new file mode 100644 index 00000000000..08874eba8bb --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/exclude.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/exclude.txt new file mode 100644 index 00000000000..08874eba8bb --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/exclude.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml new file mode 100644 index 00000000000..f408d89e28e --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml @@ -0,0 +1,2 @@ +# This file should never be staged. +key: "value" diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php new file mode 100644 index 00000000000..15b43d28125 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php new file mode 100644 index 00000000000..15b43d28125 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php @@ -0,0 +1,6 @@ +<?php + +/** + * @file + * This file should never be staged. + */ diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt new file mode 100644 index 00000000000..08874eba8bb --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess new file mode 100644 index 00000000000..e11552b41d4 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess @@ -0,0 +1 @@ +# This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json new file mode 100644 index 00000000000..d02150712c5 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json @@ -0,0 +1,85 @@ +{ + "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "version_normalized": "9.8.0.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core", + "reference": "31fd2270701526555acae45a3601c777e35508d5" + }, + "type": "drupal-core", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + }, + "installation-source": "dist", + "description": "A fake version of drupal/core", + "install-path": "../drupal/core" + }, + { + "name": "drupal/core-dev", + "version": "9.8.0", + "version_normalized": "9.8.0.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-dev", + "reference": "b99a99a11ff2779b5e4c5787dc43575382a3548c" + }, + "type": "package", + "installation-source": "dist", + "description": "A fake version of drupal/core-dev", + "install-path": "../drupal/core-dev" + }, + { + "name": "drupal/core-recommended", + "version": "9.8.0", + "version_normalized": "9.8.0.0", + "dist": { + "type": "path", + "url": "../path_repos/drupal--core-recommended", + "reference": "112e4f7cfe8312457cd0eb58dcbffebc148850d8" + }, + "type": "project", + "installation-source": "dist", + "description": "A fake version of drupal/core-recommended", + "install-path": "../drupal/core-recommended" + } + ], + "dev": true, + "dev-package-names": [ + "drupal/core-dev" + ] +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php new file mode 100644 index 00000000000..652db9ee38d --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php @@ -0,0 +1,56 @@ +<?php + +/** + * @file + */ + +return [ + 'root' => [ + 'name' => 'fake/site', + 'pretty_version' => '1.2.4', + 'version' => '1.2.4.0', + 'reference' => NULL, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../', + 'aliases' => [], + 'dev' => TRUE, + ], + 'versions' => [ + 'drupal/core' => [ + 'pretty_version' => '9.8.0', + 'version' => '9.8.0.0', + 'reference' => '31fd2270701526555acae45a3601c777e35508d5', + 'type' => 'drupal-core', + 'install_path' => __DIR__ . '/../drupal/core', + 'aliases' => [], + 'dev_requirement' => FALSE, + ], + 'drupal/core-dev' => [ + 'pretty_version' => '9.8.0', + 'version' => '9.8.0.0', + 'reference' => 'b99a99a11ff2779b5e4c5787dc43575382a3548c', + 'type' => 'package', + 'install_path' => __DIR__ . '/../drupal/core-dev', + 'aliases' => [], + 'dev_requirement' => TRUE, + ], + 'drupal/core-recommended' => [ + 'pretty_version' => '9.8.0', + 'version' => '9.8.0.0', + 'reference' => '112e4f7cfe8312457cd0eb58dcbffebc148850d8', + 'type' => 'project', + 'install_path' => __DIR__ . '/../drupal/core-recommended', + 'aliases' => [], + 'dev_requirement' => FALSE, + ], + 'fake/site' => [ + 'pretty_version' => '1.2.4', + 'version' => '1.2.4.0', + 'reference' => NULL, + 'type' => 'library', + 'install_path' => __DIR__ . '/../../', + 'aliases' => [], + 'dev_requirement' => FALSE, + ], + ], +]; diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-dev/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-dev/composer.json new file mode 100644 index 00000000000..0cbfd9f7b5f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-dev/composer.json @@ -0,0 +1 @@ +{"name":"drupal\/core-dev","description": "A fake version of drupal/core-dev","type":"package","version":"9.8.0"} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-recommended/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-recommended/composer.json new file mode 100644 index 00000000000..ca65289753d --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core-recommended/composer.json @@ -0,0 +1 @@ +{"name":"drupal/core-recommended","description": "A fake version of drupal/core-recommended","type":"project","version":"9.8.0"} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core/composer.json new file mode 100644 index 00000000000..e4412436131 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/drupal/core/composer.json @@ -0,0 +1,41 @@ +{ + "name": "drupal/core", + "type": "drupal-core", + "description": "A fake version of drupal/core", + "version": "9.8.0", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + } +} diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config b/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config new file mode 100644 index 00000000000..08874eba8bb --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config @@ -0,0 +1 @@ +This file should never be staged. diff --git a/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json new file mode 100644 index 00000000000..c48abf3e8b9 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/composer.json @@ -0,0 +1,15 @@ +{ + "name": "cweagans/composer-patches", + "description": "A fake version of cweagans/composer-patches", + "type": "composer-plugin", + "version": "1.7.333", + "extra": { + "class": "\\cweagans\\Fake\\ComposerPatches" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "autoload": { + "psr-4": {"cweagans\\Fake\\": "src"} + } +} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php b/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php new file mode 100644 index 00000000000..65f431d01ba --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/cweagans--composer-patches/src/ComposerPatches.php @@ -0,0 +1,29 @@ +<?php + +namespace cweagans\Fake; + +use Composer\Composer; +use Composer\IO\IOInterface; +use Composer\Plugin\PluginInterface; + +/** + * Dummy composer plugin implementation. + */ +class ComposerPatches implements PluginInterface { + + /** + * {@inheritdoc} + */ + public function activate(Composer $composer, IOInterface $io) {} + + /** + * {@inheritdoc} + */ + public function deactivate(Composer $composer, IOInterface $io) {} + + /** + * {@inheritdoc} + */ + public function uninstall(Composer $composer, IOInterface $io) {} + +} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-dev/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-dev/composer.json new file mode 100644 index 00000000000..0cbfd9f7b5f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-dev/composer.json @@ -0,0 +1 @@ +{"name":"drupal\/core-dev","description": "A fake version of drupal/core-dev","type":"package","version":"9.8.0"} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-recommended/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-recommended/composer.json new file mode 100644 index 00000000000..ca65289753d --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core-recommended/composer.json @@ -0,0 +1 @@ +{"name":"drupal/core-recommended","description": "A fake version of drupal/core-recommended","type":"project","version":"9.8.0"} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/drupal--core/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core/composer.json new file mode 100644 index 00000000000..e4412436131 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/drupal--core/composer.json @@ -0,0 +1,41 @@ +{ + "name": "drupal/core", + "type": "drupal-core", + "description": "A fake version of drupal/core", + "version": "9.8.0", + "extra": { + "_readme": [ + "The 'drupal-scaffold' section below is needed because 'Drupal\\auto_updates\\Validator\\ScaffoldFilePermissionsValidator'", + "uses this section to determine which files to check. The actual composer.json file for drupal/core will have more files listed", + "but this limited list is used in '\\Drupal\\Tests\\auto_updates\\Kernel\\StatusCheck\\ScaffoldFilePermissionsValidatorTest'", + "to ensure this section determines the file list." + ], + "drupal-scaffold": { + "file-mapping": { + "[project-root]/.editorconfig": "assets/scaffold/files/editorconfig", + "[project-root]/.gitattributes": "assets/scaffold/files/gitattributes", + "[project-root]/recipes/README.txt": "assets/scaffold/files/recipes.README.txt", + "[web-root]/.csslintrc": "assets/scaffold/files/csslintrc", + "[web-root]/.eslintignore": "assets/scaffold/files/eslintignore", + "[web-root]/.eslintrc.json": "assets/scaffold/files/eslintrc.json", + "[web-root]/.ht.router.php": "assets/scaffold/files/ht.router.php", + "[web-root]/.htaccess": "assets/scaffold/files/htaccess", + "[web-root]/example.gitignore": "assets/scaffold/files/example.gitignore", + "[web-root]/index.php": "assets/scaffold/files/index.php", + "[web-root]/INSTALL.txt": "assets/scaffold/files/drupal.INSTALL.txt", + "[web-root]/README.md": "assets/scaffold/files/drupal.README.md", + "[web-root]/robots.txt": "assets/scaffold/files/robots.txt", + "[web-root]/update.php": "assets/scaffold/files/update.php", + "[web-root]/sites/README.txt": "assets/scaffold/files/sites.README.txt", + "[web-root]/sites/development.services.yml": "assets/scaffold/files/development.services.yml", + "[web-root]/sites/example.settings.local.php": "assets/scaffold/files/example.settings.local.php", + "[web-root]/sites/example.sites.php": "assets/scaffold/files/example.sites.php", + "[web-root]/sites/default/default.services.yml": "assets/scaffold/files/default.services.yml", + "[web-root]/sites/default/default.settings.php": "assets/scaffold/files/default.settings.php", + "[web-root]/modules/README.txt": "assets/scaffold/files/modules.README.txt", + "[web-root]/profiles/README.txt": "assets/scaffold/files/profiles.README.txt", + "[web-root]/themes/README.txt": "assets/scaffold/files/themes.README.txt" + } + } + } +} diff --git a/core/modules/package_manager/tests/fixtures/path_repos/main_module_submodule/composer.json b/core/modules/package_manager/tests/fixtures/path_repos/main_module_submodule/composer.json new file mode 100644 index 00000000000..cee4fc43d6a --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/path_repos/main_module_submodule/composer.json @@ -0,0 +1,8 @@ +{ + "name": "drupal/main_module_submodule", + "type": "metapackage", + "version": "1.0.0", + "require": { + "drupal/main_module": "*" + } +} diff --git a/core/modules/package_manager/tests/fixtures/post_update.php b/core/modules/package_manager/tests/fixtures/post_update.php new file mode 100644 index 00000000000..a596cbe1d0f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/post_update.php @@ -0,0 +1,12 @@ +<?php + +/** + * @file + * Contains a fake database post-update function for testing. + */ + +/** + * Here is a fake post-update hook. + */ +function package_manager_post_update_test() { +} diff --git a/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml b/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml new file mode 100644 index 00000000000..98c0cf442f5 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml @@ -0,0 +1,201 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest. + +Contains metadata about the following (fake) releases of aaa_update_test module, all of which are secure, in order: +- 8.x-3.0, which is in an unsupported branch +- 8.x-2.1 +- 8.x-2.1-beta1 +- 8.x-2.1-alpha1 +- 8.x-2.0 +- 8.x-2.0-beta1 +- 8.x-2.0-alpha1 +- 8.x-1.1 +- 8.x-1.1-beta1 +- 8.x-1.1-alpha1 +- 8.x-1.0 +- 8.x-1.0-beta1 +- 8.x-1.0-alpha1 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>AAA Update test</title> +<short_name>aaa_update_test</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>8.x-2.,8.x-1.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/aaa_update_test</link> + <terms> + <term><name>Projects</name><value>AAA Update test project</value></term> + </terms> +<releases> + <release> + <name>AAA Update test 8.x-3.0</name> + <version>8.x-3.0</version> + <tag>8.x-3.0</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-3-0-release</release_link> + <download_link>http://example.com/aaa_update_test-8-3-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>AAA Update test 8.x-2.1</name> + <version>8.x-2.1</version> + <tag>8.x-2.1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-1.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> + <release> + <name>AAA Update test 8.x-2.1-beta1</name> + <version>8.x-2.1-beta1</version> + <tag>8.x-2.1-beta1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-1-beta1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-1-beta1.tar.gz</download_link> + <date>1579011300</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>AAA Update test 8.x-2.1-alpha1</name> + <version>8.x-2.1-alpha1</version> + <tag>8.x-2.1-alpha1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-1-alpha1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-1-alpha1.tar.gz</download_link> + <date>1576419300</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>AAA Update test 8.x-2.0</name> + <version>8.x-2.0</version> + <tag>8.x-2.0</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-0-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-0.tar.gz</download_link> + <date>1573827300</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>AAA Update test 8.x-2.0-beta1</name> + <version>8.x-2.0-beta1</version> + <tag>8.x-2.0-beta1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-0-beta1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-0-beta1.tar.gz</download_link> + <date>1571235300</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>AAA Update test 8.x-2.0-alpha1</name> + <version>8.x-2.0-alpha1</version> + <tag>8.x-2.0-alpha1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-2-0-alpha1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-2-0-alpha1.tar.gz</download_link> + <date>1568643300</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>AAA Update test 8.x-1.1</name> + <version>8.x-1.1</version> + <tag>8.x-1.1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-1.tar.gz</download_link> + <date>1566051300</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>AAA Update test 8.x-1.1-beta1</name> + <version>8.x-1.1-beta1</version> + <tag>8.x-1.1-beta1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-1-beta1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-1-beta1.tar.gz</download_link> + <date>1563459300</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>AAA Update test 8.x-1.1-alpha1</name> + <version>8.x-1.1-alpha1</version> + <tag>8.x-1.1-alpha1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-1-alpha1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-1-alpha1.tar.gz</download_link> + <date>1560867300</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>AAA Update test 8.x-1.0</name> + <version>8.x-1.0</version> + <tag>8.x-1.0</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-0-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-0.tar.gz</download_link> + <date>1558275300</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>AAA Update test 8.x-1.0-beta1</name> + <version>8.x-1.0-beta1</version> + <tag>8.x-1.0-beta1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-0-beta1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-0-beta1.tar.gz</download_link> + <date>1555683300</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>AAA Update test 8.x-1.0-alpha1</name> + <version>8.x-1.0-alpha1</version> + <tag>8.x-1.0-alpha1</tag> + <status>published</status> + <release_link>http://example.com/aaa_update_test-8-x-1-0-alpha1-release</release_link> + <download_link>http://example.com/aaa_update_test-8-x-1-0-alpha1.tar.gz</download_link> + <date>1553091300</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/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml b/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml new file mode 100644 index 00000000000..5981d8cc879 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Build\PackageInstallTest. + +Contains metadata about the following (fake) releases of alpha module, all of which are secure, in order: +- 1.1.0 +- 1.0.0 +--> +<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/core/modules/package_manager/tests/fixtures/release-history/drupal.10.0.0.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.10.0.0.xml new file mode 100644 index 00000000000..9b34064b5b0 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.10.0.0.xml @@ -0,0 +1,214 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\auto_updates\Functional\UpdaterFormTest + +Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order: +- 10.0.0 +- 9.7.2 +- 9.7.1 +- 9.7.0 +- 9.6.1 +- 9.6.0 +- 9.5.1 +- 9.5.0 +- 9.4.0, which is in an unsupported branch +- 9.7.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.5.,9.6.,9.7.,10.0.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 10.0.0</name> + <version>10.0.0</version> + <status>published</status> + <release_link>http://example.com/drupal-10-0-0-release</release_link> + <download_link>http://example.com/drupal-10-0-0.tar.gz</download_link> + <date>1250425521</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>Drupal 9.7.2</name> + <version>9.7.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-2-release</release_link> + <download_link>http://example.com/drupal-9-7-2.tar.gz</download_link> + <date>1250425521</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>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.6.1</name> + <version>9.6.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-1-release</release_link> + <download_link>http://example.com/drupal-9-6-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.6.0</name> + <version>9.6.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-release</release_link> + <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.5.1</name> + <version>9.5.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-5-1-release</release_link> + <download_link>http://example.com/drupal-9-5-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.5.0</name> + <version>9.5.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-5-0-release</release_link> + <download_link>http://example.com/drupal-9-5-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.4.0</name> + <version>9.4.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-4-0-release</release_link> + <download_link>http://example.com/drupal-9-4-0.tar.gz</download_link> + <date>1240424421</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>Drupal 9.7.x-dev</name> + <version>9.7.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-7-x-dex.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-alpha1.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-alpha1.xml new file mode 100644 index 00000000000..2f3e6f23e7f --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-alpha1.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\ProjectInfoTest. + +Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order: +- 9.8.0-alpha1 +- 9.7.1 +- 9.7.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-beta1.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-beta1.xml new file mode 100644 index 00000000000..c34d58ce5da --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-beta1.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\auto_updates\Functional\UpdaterFormTest. + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.0-beta1 +- 9.7.0 +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.8.,9.7.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.0-beta1</name> + <version>9.8.0-beta1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-beta1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-beta1-.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-rc1.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-rc1.xml new file mode 100644 index 00000000000..c7c60b26be1 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.0-rc1.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\auto_updates\Functional\UpdaterFormTest. + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.0-rc1 +- 9.7.0 +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.8.,9.7.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.0-rc1</name> + <version>9.8.0-rc1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-rc1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-rc1-.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-empty_supported_branches.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-empty_supported_branches.xml new file mode 100644 index 00000000000..e2c16bdd013 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-empty_supported_branches.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\ProjectInfoTest. + +Contains metadata about releases of Drupal core with no supported branches: +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches/> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-extra.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-extra.xml new file mode 100644 index 00000000000..e820b03c893 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-extra.xml @@ -0,0 +1,157 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicyValidatorTest. + +Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order: +- 9.8.1-rc3 +- 9.8.1-beta2 +- 9.8.1-alpha1 +- 9.7.1 +- 9.7.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.1-rc3</name> + <version>9.8.1-rc3</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-rc3-release</release_link> + <download_link>http://example.com/drupal-9-8-1-rc3.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.1-beta2</name> + <version>9.8.1-beta2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-beta2-release</release_link> + <download_link>http://example.com/drupal-9-8-1-beta2.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.1-alpha1</name> + <version>9.8.1-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-1-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.1-beta2</name> + <version>9.8.1-beta2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-beta2-release</release_link> + <download_link>http://example.com/drupal-9-8-1-beta2.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml new file mode 100644 index 00000000000..93098f02a92 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml @@ -0,0 +1,122 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\auto_updates_extensions\Build\ModuleUpdateTest +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\auto_updates\Build\CoreUpdateTest +- \Drupal\Tests\auto_updates\Functional\AvailableUpdatesReportTest +- \Drupal\Tests\auto_updates\Functional\ClickableHelpTest +- \Drupal\Tests\auto_updates\Functional\StatusCheckTest +- \Drupal\Tests\auto_updates\Functional\UpdaterFormNoRecommendedReleaseMessageTest +- \Drupal\Tests\auto_updates\Kernel\CronUpdaterTest +- \Drupal\Tests\auto_updates\Kernel\UpdaterTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\CronFrequencyValidatorTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\StatusCheckerTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicyValidatorTest + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.1, which is a security release +- 9.8.0, which is insecure +- 9.8.0-alpha1, which is insecure +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Security update</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1-.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-supported_branches_not_set.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-supported_branches_not_set.xml new file mode 100644 index 00000000000..e3030e71c3c --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-supported_branches_not_set.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\ProjectInfoTest. + +Contains metadata about releases of Drupal core with <supported_branches></supported_branches> not set: +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml new file mode 100644 index 00000000000..128010a9b5b --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml @@ -0,0 +1,216 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\auto_updates\Functional\AvailableUpdatesReportTest +- \Drupal\Tests\auto_updates\Kernel\ReleaseChooserTest + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.2 +- 9.8.1, which is a security update +- 9.8.1-beta1, which is a security update +- 9.8.0, which is insecure +- 9.8.0-alpha1 +- 9.7.1, which is a security update +- 9.7.0, which is insecure +- 9.7.0-alpha1 +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</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>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Security update</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.1-beta1</name> + <version>9.8.1-beta1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-beta1-release</release_link> + <download_link>http://example.com/drupal-9-8-1-beta1.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Security release</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Security update</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Insecure</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml new file mode 100644 index 00000000000..6220cae3b07 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml @@ -0,0 +1,237 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicyValidatorTest + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.2 +- 9.8.1, which is unsupported +- 9.8.0, which is unpublished +- 9.8.0-alpha1 +- 9.7.1 +- 9.7.0 +- 9.7.0-alpha1 +- 9.6.1, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</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>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</date> + <terms> + <term> + <name>Release type</name> + <value>New features</value> + </term> + <term> + <name>Release type</name> + <value>Bug fixes</value> + </term> + <term> + <name>Release type</name> + <value>Unsupported</value> + </term> + </terms> + </release> + <release> + <name>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>unpublished</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.6.1</name> + <version>9.6.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-1-release</release_link> + <download_link>http://example.com/drupal-9-6-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.6.0</name> + <version>9.6.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-release</release_link> + <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.6.0-alpha1</name> + <version>9.6.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml new file mode 100644 index 00000000000..cf2384f0347 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml @@ -0,0 +1,241 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\auto_updates_extensions\Functional\UpdaterFormTest +- \Drupal\Tests\auto_updates_extensions\Kernel\Validator\UpdateReleaseValidatorTest +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest +- \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest +- \Drupal\Tests\auto_updates\Functional\AvailableUpdatesReportTest +- \Drupal\Tests\auto_updates\Functional\UpdaterFormNoRecommendedReleaseMessageTest +- \Drupal\Tests\auto_updates\Functional\UpdaterFormTest +- \Drupal\Tests\auto_updates\Kernel\CronUpdaterTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicyValidatorTest +- \Drupal\Tests\auto_updates\Kernel\StatusCheck\VersionPolicy\SupportedBranchInstalledTest + +Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order: +- 9.8.2 +- 9.8.1 +- 9.8.0 +- 9.8.0-alpha1 +- 9.7.1 +- 9.7.0 +- 9.7.0-alpha1 +- 9.6.1, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.8.x-dev +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</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>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.6.1</name> + <version>9.6.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-1-release</release_link> + <download_link>http://example.com/drupal-9-6-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.6.0</name> + <version>9.6.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-release</release_link> + <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.6.0-alpha1</name> + <version>9.6.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml new file mode 100644 index 00000000000..e0d599c9b0b --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml @@ -0,0 +1,233 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\ProjectInfoTest. + +Contains metadata about the following (fake) releases of Drupal core, in order: +- 9.8.2 +- 9.8.1 +- 9.8.0 +- 9.8.0-alpha1 +- 9.7.1 +- 9.7.0 +- 9.7.0-alpha1 +- 9.6.1, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.6.0, which is in an unsupported branch +- 9.8.x-dev + +What's special about this file is that the project as a whole has a status other than "published". +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> + <title>Drupal</title> + <short_name>drupal</short_name> + <dc:creator>Drupal</dc:creator> + <supported_branches>9.7.,9.8.</supported_branches> + <project_status>any status besides published</project_status> + <link>http://example.com/project/drupal</link> + <terms> + <term> + <name>Projects</name> + <value>Drupal project</value> + </term> + </terms> + <releases> + <release> + <name>Drupal 9.8.2</name> + <version>9.8.2</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-2-release</release_link> + <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link> + <date>1250425521</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>Drupal 9.8.1</name> + <version>9.8.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-1-release</release_link> + <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.8.0</name> + <version>9.8.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-release</release_link> + <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.0-alpha1</name> + <version>9.8.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.1</name> + <version>9.7.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-1-release</release_link> + <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.7.0</name> + <version>9.7.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-release</release_link> + <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.7.0-alpha1</name> + <version>9.7.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.6.1</name> + <version>9.6.1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-1-release</release_link> + <download_link>http://example.com/drupal-9-6-1.tar.gz</download_link> + <date>1250425521</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>Drupal 9.6.0</name> + <version>9.6.0</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-release</release_link> + <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link> + <date>1250424521</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>Drupal 9.6.0-alpha1</name> + <version>9.6.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link> + <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Drupal 9.8.x-dev</name> + <version>9.8.x-dev</version> + <status>published</status> + <release_link>http://example.com/drupal-9-8-x-dex-release</release_link> + <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link> + <date>1250424521</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/core/modules/package_manager/tests/fixtures/release-history/main_module.1.0.0.xml b/core/modules/package_manager/tests/fixtures/release-history/main_module.1.0.0.xml new file mode 100644 index 00000000000..a1702e42f72 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/main_module.1.0.0.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Build\PackageInstallTest. + +Contains metadata about the following (fake) releases of main_module, all of which are secure, in order: +- 1.0.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Main Module</title> +<short_name>main_module</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>1.0.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/main_module</link> + <terms> + <term><name>Projects</name><value>Main Module project</value></term> + </terms> +<releases> + <release> + <name>Main Module 1.0.0</name> + <version>1.0.0</version> + <tag>1.0.0</tag> + <status>published</status> + <release_link>http://example.com/main_module-1-0-0-release</release_link> + <download_link>http://example.com/main_module-1-0-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> +</releases> +</project> diff --git a/core/modules/package_manager/tests/fixtures/release-history/package_manager_test_update.7.0.1.xml b/core/modules/package_manager/tests/fixtures/release-history/package_manager_test_update.7.0.1.xml new file mode 100644 index 00000000000..dd5c12a33a6 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/package_manager_test_update.7.0.1.xml @@ -0,0 +1,166 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by: +- \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest +- \Drupal\Tests\package_manager\Kernel\ProjectInfoTest + +Contains metadata about the following (fake) releases of package_manager_test_update module, all of which are secure, in order: +- 7.0.1 +- 7.0.0 +- 7.0.0-alpha1 +- 8.x-6.2 +- 8.x-6.1 +- 8.x-6.0 +- 8.x-6.0-alpha1 +- 7.0.x-dev +- 8.x-6.x-dev +- 8.x-5.x - An invalid release to ensure invalid releases do not affect processing other releases. +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Package Manager Test Update</title> +<short_name>package_manager_test_update</short_name> +<dc:creator>Package Manager</dc:creator> +<supported_branches>7.0.,8.x-6.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/package_manager_test_update</link> + <terms> + <term><name>Projects</name><value>Package Manager Test Update project</value></term> + </terms> +<releases> + <release> + <name>Package Manager Test Update 7.0.1</name> + <version>7.0.1</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-7-1-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-7-1.tar.gz</download_link> + <date>1250425521</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>Package Manager Test Update 7.0.0</name> + <version>7.0.0</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-7-0-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-7-0.tar.gz</download_link> + <date>1250424521</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>Package Manager Test Update 7.0.0-alpha1</name> + <version>7.0.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-7-0-alpha1-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-7-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Package Manager Test Update 8.x-6.2</name> + <version>8.x-6.2</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-2-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-2.tar.gz</download_link> + <date>1250425521</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>Package Manager Test Update 8.x-6.1</name> + <version>8.x-6.1</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-1-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-1.tar.gz</download_link> + <date>1250425521</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>Package Manager Test Update 8.x-6.0</name> + <version>8.x-6.0</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-0-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-0.tar.gz</download_link> + <date>1250424521</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>Package Manager Test Update 8.x-6.0-alpha1</name> + <version>8.x-6.0-alpha1</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-0-alpha1-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-0-alpha1.tar.gz</download_link> + <date>1250424521</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>Package Manager Test Update 7.0.0-dev</name> + <version>7.0.x-dev</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-7-0-dev-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-7-0-dev.tar.gz</download_link> + <date>1250424521</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>Package Manager Test Update 8.x-6.x-dev</name> + <version>8.x-6.x-dev</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-8-0-dev-release</release_link> + <download_link>http://example.com/package_manager_test_update-9-8-0-dev.tar.gz</download_link> + <date>1250424521</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>Package Manager Test Update 8.x-5.x</name> + <version>8.x-5.x</version> + <status>published</status> + <release_link>http://example.com/package_manager_test_update-9-5-0-dev-release</release_link> + <download_link/> + <date/> + <filesize/> + <files> + <file> + <url/> + <archive_type/> + <md5/> + <size/> + </file> + <file> + <url/> + <archive_type/> + <md5/> + <size/> + </file> + </files> + <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/core/modules/package_manager/tests/fixtures/release-history/package_manager_theme.1.1.xml b/core/modules/package_manager/tests/fixtures/release-history/package_manager_theme.1.1.xml new file mode 100644 index 00000000000..69779f4ce90 --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/package_manager_theme.1.1.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest. + +Contains metadata about the following (fake) releases of Package Manager Theme, all of which are secure, in order: +- 8.2.0, which is in an unsupported branch +- 8.1.1 +- 8.1.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Package Manager Theme</title> +<short_name>package_manager_theme</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>8.0.,8.1.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/package_manager_theme</link> + <terms> + <term><name>Projects</name><value>Package Manager Theme project</value></term> + </terms> +<releases> + <release> + <name>Package Manager Theme 8.2.0</name> + <version>8.2.0</version> + <tag>8.2.0</tag> + <status>published</status> + <release_link>http://example.com/package_manager_theme-8-2-0-release</release_link> + <download_link>http://example.com/package_manager_theme-8-2-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>Package Manager Theme 8.1.1</name> + <version>8.1.1</version> + <tag>8.1.1</tag> + <status>published</status> + <release_link>http://example.com/package_manager_theme-8-1-1-release</release_link> + <download_link>http://example.com/package_manager_theme-8-1-1.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> + <release> + <name>Package Manager Theme 8.1.0</name> + <version>8.1.0</version> + <tag>8.1.0</tag> + <status>published</status> + <release_link>http://example.com/package_manager_theme-8-1-0-release</release_link> + <download_link>http://example.com/package_manager_theme-8-1-0.tar.gz</download_link> + <date>1573827300</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/core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml b/core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml new file mode 100644 index 00000000000..ddebbd803cf --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml @@ -0,0 +1,201 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Kernel\SupportedReleaseValidatorTest. + +Contains metadata about the following (fake) releases of semver_test module, all of which are secure, in order: +- 8.2.0, which is in an unsupported branch +- 8.1.1 +- 8.1.1-beta1 +- 8.1.1-alpha1 +- 8.1.0 +- 8.1.0-beta1 +- 8.1.0-alpha1 +- 8.0.1 +- 8.0.1-beta1 +- 8.0.1-alpha1 +- 8.0.0 +- 8.0.0-beta1 +- 8.0.0-alpha1 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Semver Test</title> +<short_name>semver_test</short_name> +<dc:creator>Drupal</dc:creator> +<supported_branches>8.0.,8.1.</supported_branches> +<project_status>published</project_status> +<link>http://example.com/project/semver_test</link> + <terms> + <term><name>Projects</name><value>Semver Test project</value></term> + </terms> +<releases> + <release> + <name>Semver Test 8.2.0</name> + <version>8.2.0</version> + <tag>8.2.0</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-2-0-release</release_link> + <download_link>http://example.com/semver_test-8-2-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>Semver Test 8.1.1</name> + <version>8.1.1</version> + <tag>8.1.1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-1-release</release_link> + <download_link>http://example.com/semver_test-8-1-1.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> + <release> + <name>Semver Test 8.1.1-beta1</name> + <version>8.1.1-beta1</version> + <tag>8.1.1-beta1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-1-beta1-release</release_link> + <download_link>http://example.com/semver_test-8-1-1-beta1.tar.gz</download_link> + <date>1579011300</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>Semver Test 8.1.1-alpha1</name> + <version>8.1.1-alpha1</version> + <tag>8.1.1-alpha1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-1-alpha1-release</release_link> + <download_link>http://example.com/semver_test-8-1-1-alpha1.tar.gz</download_link> + <date>1576419300</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>Semver Test 8.1.0</name> + <version>8.1.0</version> + <tag>8.1.0</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-0-release</release_link> + <download_link>http://example.com/semver_test-8-1-0.tar.gz</download_link> + <date>1573827300</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>Semver Test 8.1.0-beta1</name> + <version>8.1.0-beta1</version> + <tag>8.1.0-beta1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-0-beta1-release</release_link> + <download_link>http://example.com/semver_test-8-1-0-beta1.tar.gz</download_link> + <date>1571235300</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>Semver Test 8.1.0-alpha1</name> + <version>8.1.0-alpha1</version> + <tag>8.1.0-alpha1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-1-0-alpha1-release</release_link> + <download_link>http://example.com/semver_test-8-1-0-alpha1.tar.gz</download_link> + <date>1568643300</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>Semver Test 8.0.1</name> + <version>8.0.1</version> + <tag>8.0.1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-1-release</release_link> + <download_link>http://example.com/semver_test-8-0-1.tar.gz</download_link> + <date>1566051300</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>Semver Test 8.0.1-beta1</name> + <version>8.0.1-beta1</version> + <tag>8.0.1-beta1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-1-beta1-release</release_link> + <download_link>http://example.com/semver_test-8-0-1-beta1.tar.gz</download_link> + <date>1563459300</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>Semver Test 8.0.1-alpha1</name> + <version>8.0.1-alpha1</version> + <tag>8.0.1-alpha1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-1-alpha1-release</release_link> + <download_link>http://example.com/semver_test-8-0-1-alpha1.tar.gz</download_link> + <date>1560867300</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>Semver Test 8.0.0</name> + <version>8.0.0</version> + <tag>8.0.0</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-0-release</release_link> + <download_link>http://example.com/semver_test-8-0-0.tar.gz</download_link> + <date>1558275300</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>Semver Test 8.0.0-beta1</name> + <version>8.0.0-beta1</version> + <tag>8.0.0-beta1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-0-beta1-release</release_link> + <download_link>http://example.com/semver_test-8-0-0-beta1.tar.gz</download_link> + <date>1555683300</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>Semver Test 8.0.0-alpha1</name> + <version>8.0.0-alpha1</version> + <tag>8.0.0-alpha1</tag> + <status>published</status> + <release_link>http://example.com/semver_test-8-0-0-alpha1-release</release_link> + <download_link>http://example.com/semver_test-8-0-0-alpha1.tar.gz</download_link> + <date>1553091300</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/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml b/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml new file mode 100644 index 00000000000..ca709ff98bd --- /dev/null +++ b/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +This fixture is used by \Drupal\Tests\package_manager\Build\PackageUpdateTest. + +Contains metadata about the following (fake) releases of updated_module, all of which are secure, in order: +- 1.1.0 +- 1.0.0 +--> +<project xmlns:dc="http://purl.org/dc/elements/1.1/"> +<title>Updated Module</title> +<short_name>updated_module</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>Updated Module project</value></term> + </terms> +<releases> + <release> + <name>Updated Module 1.1.0</name> + <version>1.1.0</version> + <tag>1.1.0</tag> + <status>published</status> + <release_link>http://example.com/updated_module-1-1-0-release</release_link> + <download_link>http://example.com/updated_module-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>Updated Module 1.0.0</name> + <version>1.0.0</version> + <tag>1.0.0</tag> + <status>published</status> + <release_link>http://example.com/updated_module-1-0-0-release</release_link> + <download_link>http://example.com/updated_module-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/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml b/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml new file mode 100644 index 00000000000..cebed1395be --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml @@ -0,0 +1,4 @@ +name: 'Fixture manipulator' +description: 'Manipulate fixtures for tests.' +type: module +package: Testing diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.services.yml b/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.services.yml new file mode 100644 index 00000000000..4ea503f7188 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.services.yml @@ -0,0 +1,7 @@ +services: + _defaults: + autowire: true + Drupal\fixture_manipulator\StageFixtureManipulator: + decorates: 'PhpTuf\ComposerStager\API\Core\BeginnerInterface' + Drupal\fixture_manipulator\ProcessFactory: + decorates: 'PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface' diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php new file mode 100644 index 00000000000..d486eb2b5aa --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\fixture_manipulator; + +use Drupal\package_manager\PathLocator; + +/** + * A fixture manipulator for the active directory. + */ +final class ActiveFixtureManipulator extends FixtureManipulator { + + /** + * {@inheritdoc} + */ + public function commitChanges(?string $dir = NULL): void { + if ($dir) { + throw new \UnexpectedValueException("$dir cannot be specific for a ActiveFixtureManipulator instance"); + } + $dir = \Drupal::service(PathLocator::class)->getProjectRoot(); + parent::doCommitChanges($dir); + } + +} diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php new file mode 100644 index 00000000000..a0410cf7514 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php @@ -0,0 +1,645 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\fixture_manipulator; + +use Drupal\Component\FileSystem\FileSystem; +use Drupal\Component\Utility\NestedArray; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; +use Symfony\Component\Filesystem\Filesystem as SymfonyFileSystem; +use Drupal\Component\Serialization\Yaml; + +/** + * Manipulates a test fixture using Composer commands. + * + * The composer.json file CANNOT be safely created or modified using the + * json_encode() function, because Composer does not use a real JSON parser — it + * updates composer.json using \Composer\Json\JsonManipulator, which is known to + * choke in a number of scenarios. + * + * @see https://www.drupal.org/i/3346628 + */ +class FixtureManipulator { + + protected const PATH_REPO_STATE_KEY = self::class . '-path-repo-base'; + + /** + * Whether changes are currently being committed. + * + * @var bool + */ + private bool $committingChanges = FALSE; + + /** + * Arguments to manipulator functions. + * + * @var array + */ + private array $manipulatorArguments = []; + + /** + * Whether changes have been committed. + * + * @var bool + */ + protected bool $committed = FALSE; + + /** + * The fixture directory. + * + * @var string + */ + protected string $dir; + + /** + * Validate the fixtures still passes `composer validate`. + */ + private function validateComposer(): void { + /** @var \PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface $runner */ + $runner = \Drupal::service(ComposerProcessRunnerInterface::class); + $runner->run([ + 'validate', + '--check-lock', + '--no-check-publish', + '--with-dependencies', + '--no-interaction', + '--ansi', + '--no-cache', + "--working-dir={$this->dir}", + // Unlike ComposerInspector::validate(), explicitly do NOT validate + // plugins, to allow for testing edge cases. + '--no-plugins', + // Dummy packages are not meant for publishing, so do not validate that. + '--no-check-publish', + '--no-check-version', + ]); + } + + /** + * Adds a package. + * + * @param array $package + * A Composer package definition. Must include the `name` and `type` keys. + * @param bool $is_dev_requirement + * Whether the package is a development requirement. + * @param bool $allow_plugins + * Whether to use the '--no-plugins' option. + * @param array|null $extra_files + * An array extra files to create in the package. The keys are the file + * paths under package and values are the file contents. + */ + public function addPackage(array $package, bool $is_dev_requirement = FALSE, bool $allow_plugins = FALSE, ?array $extra_files = NULL): self { + if (!$this->committingChanges) { + // To pass Composer validation all packages must have a version specified. + if (!isset($package['version'])) { + $package['version'] = '1.2.3'; + } + $this->queueManipulation('addPackage', [$package, $is_dev_requirement, $allow_plugins, $extra_files]); + return $this; + } + + // Basic validation so we can defer the rest to `composer` commands. + foreach (['name', 'type'] as $required_key) { + if (!isset($package[$required_key])) { + throw new \UnexpectedValueException("The '$required_key' is required when calling ::addPackage()."); + } + } + if (!preg_match('/\w+\/\w+/', $package['name'])) { + throw new \UnexpectedValueException(sprintf("'%s' is not a valid package name.", $package['name'])); + } + + // `composer require` happily will re-require already required packages. + // Prevent test authors from thinking this has any effect when it does not. + $json = $this->runComposerCommand(['show', '--name-only', '--format=json'])->stdout; + $installed_package_names = array_column(json_decode($json)?->installed ?? [], 'name'); + if (in_array($package['name'], $installed_package_names)) { + throw new \LogicException(sprintf("Expected package '%s' to not be installed, but it was.", $package['name'])); + } + + $repo_path = $this->addRepository($package); + if (is_null($extra_files) && isset($package['type']) && in_array($package['type'], ['drupal-module', 'drupal-theme', 'drupal-profile'], TRUE)) { + // For Drupal projects if no files are provided create an info.yml file + // that assumes the project and package names match. + [, $package_name] = explode('/', $package['name']); + $project_name = str_replace('-', '_', $package_name); + $project_info_data = [ + 'name' => $package['name'], + 'project' => $project_name, + ]; + $extra_files["$project_name.info.yml"] = Yaml::encode($project_info_data); + } + if (!empty($extra_files)) { + $fs = new SymfonyFileSystem(); + foreach ($extra_files as $file_name => $file_contents) { + if (str_contains($file_name, DIRECTORY_SEPARATOR)) { + $file_dir = dirname("$repo_path/$file_name"); + if (!is_dir($file_dir)) { + $fs->mkdir($file_dir); + } + } + assert(file_put_contents("$repo_path/$file_name", $file_contents) !== FALSE); + } + } + return $this->requirePackage($package['name'], $package['version'], $is_dev_requirement, $allow_plugins); + } + + /** + * Requires a package. + * + * @param string $package + * A package name. + * @param string $version + * A version constraint. + * @param bool $is_dev_requirement + * Whether the package is a development requirement. + * @param bool $allow_plugins + * Whether to use the '--no-plugins' option. + */ + public function requirePackage(string $package, string $version, bool $is_dev_requirement = FALSE, bool $allow_plugins = FALSE): self { + if (!$this->committingChanges) { + $this->queueManipulation('requirePackage', func_get_args()); + return $this; + } + + $command_options = ['require', "$package:$version"]; + if ($is_dev_requirement) { + $command_options[] = '--dev'; + } + // Unlike ComposerInspector::validate(), explicitly do NOT validate plugins. + if (!$allow_plugins) { + $command_options[] = '--no-plugins'; + } + $this->runComposerCommand($command_options); + return $this; + } + + /** + * Modifies a package's composer.json properties. + * + * @param string $package_name + * The name of the package to modify. + * @param string $version + * The version to use for the modified package. Can be the same as the + * original version, or a different version. + * @param array $config + * The config to be added to the package's composer.json. + * @param bool $is_dev_requirement + * Whether the package is a development requirement. + * + * @see \Composer\Command\ConfigCommand + */ + public function modifyPackageConfig(string $package_name, string $version, array $config, bool $is_dev_requirement = FALSE): self { + if (!$this->committingChanges) { + $this->queueManipulation('modifyPackageConfig', func_get_args()); + return $this; + } + $package = [ + 'name' => $package_name, + 'version' => $version, + ] + $config; + $this->addRepository($package); + $this->runComposerCommand(array_filter(['require', "$package_name:$version", $is_dev_requirement ? '--dev' : NULL])); + return $this; + } + + /** + * Sets a package version. + * + * @param string $package_name + * The package name. + * @param string $version + * The version. + * @param bool $is_dev_requirement + * Whether the package is a development requirement. + * + * @return $this + */ + public function setVersion(string $package_name, string $version, bool $is_dev_requirement = FALSE): self { + if (!$this->committingChanges) { + $this->queueManipulation('setVersion', func_get_args()); + return $this; + } + return $this->modifyPackageConfig($package_name, $version, [], $is_dev_requirement); + } + + /** + * Removes a package. + * + * @param string $name + * The name of the package to remove. + * @param bool $is_dev_requirement + * Whether the package is a developer requirement. + */ + public function removePackage(string $name, bool $is_dev_requirement = FALSE): self { + if (!$this->committingChanges) { + $this->queueManipulation('removePackage', func_get_args()); + return $this; + } + + $output = $this->runComposerCommand(array_filter(['remove', $name, $is_dev_requirement ? '--dev' : NULL])); + // `composer remove` will not set exit code 1 whenever a non-required + // package is being removed. + // @see \Composer\Command\RemoveCommand + if (str_contains($output->stderr, 'not required in your composer.json and has not been removed')) { + $output->stderr = str_replace("./composer.json has been updated\n", '', $output->stderr); + throw new \LogicException($output->stderr); + } + return $this; + } + + /** + * Adds a project at a path. + * + * @param string $path + * The path. + * @param string|null $project_name + * (optional) The project name. If none is specified the last part of the + * path will be used. + * @param string|null $file_name + * (optional) The file name. If none is specified the project name will be + * used. + */ + public function addProjectAtPath(string $path, ?string $project_name = NULL, ?string $file_name = NULL): self { + if (!$this->committingChanges) { + $this->queueManipulation('addProjectAtPath', func_get_args()); + return $this; + } + $path = $this->dir . "/$path"; + if (file_exists($path)) { + throw new \LogicException("'$path' path already exists."); + } + $fs = new SymfonyFileSystem(); + $fs->mkdir($path); + if ($project_name === NULL) { + $project_name = basename($path); + } + if ($file_name === NULL) { + $file_name = "$project_name.info.yml"; + } + assert(file_put_contents("$path/$file_name", Yaml::encode(['project' => $project_name])) !== FALSE); + return $this; + } + + /** + * Modifies core packages. + * + * @param string $version + * Target version. + */ + public function setCorePackageVersion(string $version): self { + $this->setVersion('drupal/core', $version); + $this->setVersion('drupal/core-recommended', $version); + $this->setVersion('drupal/core-dev', $version); + return $this; + } + + /** + * Modifies the project root's composer.json properties. + * + * @see \Composer\Command\ConfigCommand + * + * @param array $additional_config + * The configuration to add. + */ + public function addConfig(array $additional_config): self { + if (empty($additional_config)) { + throw new \InvalidArgumentException('No config to add.'); + } + + if (!$this->committingChanges) { + $this->queueManipulation('addConfig', func_get_args()); + return $this; + } + $clean_value = function ($value) { + return $value === FALSE ? 'false' : $value; + }; + + foreach ($additional_config as $key => $value) { + $command = ['config']; + if (is_array($value)) { + $value = json_encode($value, JSON_UNESCAPED_SLASHES); + $command[] = '--json'; + } + else { + $value = $clean_value($value); + } + $command[] = $key; + $command[] = $value; + $this->runComposerCommand($command); + } + $this->runComposerCommand(['update', '--lock']); + + return $this; + } + + /** + * Commits the changes to the directory. + */ + public function commitChanges(string $dir): void { + $this->doCommitChanges($dir); + $this->committed = TRUE; + } + + /** + * Commits all the changes. + * + * @param string $dir + * The directory to commit the changes to. + */ + final protected function doCommitChanges(string $dir): void { + if ($this->committed) { + throw new \BadMethodCallException('Already committed.'); + } + $this->dir = $dir; + $this->setUpRepos(); + $this->committingChanges = TRUE; + $manipulator_arguments = $this->getQueuedManipulationItems(); + $this->clearQueuedManipulationItems(); + foreach ($manipulator_arguments as $method => $argument_sets) { + // @todo Attempt to make fewer Composer calls in + // https://drupal.org/i/3345639. + foreach ($argument_sets as $argument_set) { + $this->{$method}(...$argument_set); + } + } + $this->committed = TRUE; + $this->committingChanges = FALSE; + $this->validateComposer(); + } + + /** + * Ensure that changes were committed before object is destroyed. + */ + public function __destruct() { + if (!$this->committed && !empty($this->manipulatorArguments)) { + throw new \LogicException('commitChanges() must be called.'); + } + } + + /** + * Creates an empty .git folder after being provided a path. + */ + public function addDotGitFolder(string $path): self { + if (!$this->committingChanges) { + $this->queueManipulation('addDotGitFolder', func_get_args()); + return $this; + } + if (!is_dir($path)) { + throw new \LogicException("No directory exists at $path."); + } + $fs = new SymfonyFileSystem(); + $git_directory_path = $path . "/.git"; + if (is_dir($git_directory_path)) { + throw new \LogicException("A .git directory already exists at $path."); + } + $fs->mkdir($git_directory_path); + return $this; + } + + /** + * Queues manipulation arguments to be called in ::doCommitChanges(). + * + * @param string $method + * The method name. + * @param array $arguments + * The arguments. + */ + protected function queueManipulation(string $method, array $arguments): void { + $this->manipulatorArguments[$method][] = $arguments; + } + + /** + * Clears all queued manipulation items. + */ + protected function clearQueuedManipulationItems(): void { + $this->manipulatorArguments = []; + } + + /** + * Gets all queued manipulation items. + * + * @return array + * The queued manipulation items as set by calls to ::queueManipulation(). + */ + protected function getQueuedManipulationItems(): array { + return $this->manipulatorArguments; + } + + protected function runComposerCommand(array $command_options): OutputCallbackInterface { + $plain_output = new class() implements OutputCallbackInterface { + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable + public string $stdout = ''; + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable + public string $stderr = ''; + + /** + * {@inheritdoc} + */ + public function __invoke(OutputTypeEnum $type, string $buffer): void { + if ($type === OutputTypeEnum::OUT) { + $this->stdout .= $buffer; + } + elseif ($type === OutputTypeEnum::ERR) { + $this->stderr .= $buffer; + } + } + + /** + * {@inheritdoc} + */ + public function clearErrorOutput(): void { + throw new \LogicException("Unexpected call to clearErrorOutput()."); + } + + /** + * {@inheritdoc} + */ + public function clearOutput(): void { + throw new \LogicException("Unexpected call to clearOutput()."); + } + + /** + * {@inheritdoc} + */ + public function getErrorOutput(): array { + throw new \LogicException("Unexpected call to getErrorOutput()."); + } + + /** + * {@inheritdoc} + */ + public function getOutput(): array { + throw new \LogicException("Unexpected call to getOutput()."); + } + + }; + /** @var \PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface $runner */ + $runner = \Drupal::service(ComposerProcessRunnerInterface::class); + $command_options[] = "--working-dir={$this->dir}"; + $runner->run($command_options, callback: $plain_output); + return $plain_output; + } + + /** + * Transform the received $package into options for `composer init`. + * + * @param array $package + * A Composer package definition. Must include the `name` and `type` keys. + * + * @return array + * The corresponding `composer init` options. + */ + private static function getComposerInitOptionsForPackage(array $package): array { + return array_filter(array_map(function ($k, $v) { + switch ($k) { + case 'name': + case 'description': + case 'type': + return "--$k=$v"; + + case 'require': + case 'require-dev': + if (empty($v)) { + return NULL; + } + $requirements = array_map( + fn(string $req_package, string $req_version): string => "$req_package:$req_version", + array_keys($v), + array_values($v) + ); + return "--$k=" . implode(',', $requirements); + + case 'version': + // This gets set in the repository metadata itself. + return NULL; + + case 'extra': + // Cannot be set using `composer init`, only `composer config` can. + return NULL; + + default: + throw new \InvalidArgumentException($k); + } + }, array_keys($package), array_values($package))); + } + + /** + * Creates a path repo. + * + * @param array $package + * A Composer package definition. Must include the `name` and `type` keys. + * @param string $repo_path + * The path at which to create a path repo for this package. + * @param string|null $original_repo_path + * If NULL: this is the first version of this package. Otherwise: a string + * containing the path repo to the first version of this package. This will + * be used to automatically inherit the same files (typically *.info.yml). + */ + private function createPathRepo(array $package, string $repo_path, ?string $original_repo_path): void { + $fs = new SymfonyFileSystem(); + if (is_dir($repo_path)) { + throw new \LogicException("A path repo already exists at $repo_path."); + } + // Create the repo if it does not exist. + $fs->mkdir($repo_path); + // Forks also get the original's additional files (e.g. *.info.yml files). + if ($original_repo_path) { + $fs->mirror($original_repo_path, $repo_path); + // composer.json will be freshly generated by `composer init` below. + $fs->remove($repo_path . '/composer.json'); + } + // Switch the working directory from project root to repo path. + $project_root_dir = $this->dir; + $this->dir = $repo_path; + // Create a composer.json file using `composer init`. + $this->runComposerCommand(['init', ...static::getComposerInitOptionsForPackage($package)]); + // Set the `extra` property in the generated composer.json file using + // `composer config`, because `composer init` does not support it. + foreach ($package['extra'] ?? [] as $extra_property => $extra_value) { + $this->runComposerCommand(['config', "extra.$extra_property", '--json', json_encode($extra_value, JSON_UNESCAPED_SLASHES)]); + } + // Restore the project root as the working directory. + $this->dir = $project_root_dir; + } + + /** + * Adds a path repository. + * + * @param array $package + * A Composer package definition. Must include the `name` and `type` keys. + * + * @return string + * The repository path. + */ + private function addRepository(array $package): string { + $name = $package['name']; + $path_repo_base = \Drupal::state()->get(self::PATH_REPO_STATE_KEY); + $repo_path = "$path_repo_base/" . str_replace('/', '--', $name); + + // Determine if the given $package is a new package or a fork of an existing + // one (that means it's either the same version but with other metadata, or + // a new version with other metadata). Existing path repos are never + // modified, not even if the same version of a package is assigned other + // metadata. This allows always comparing with the original metadata. + $is_new_or_fork = !is_dir($repo_path) ? 'new' : 'fork'; + if ($is_new_or_fork === 'fork') { + $original_composer_json_path = $repo_path . DIRECTORY_SEPARATOR . 'composer.json'; + $original_repo_path = $repo_path; + $original_composer_json_data = json_decode(file_get_contents($original_composer_json_path), TRUE, flags: JSON_THROW_ON_ERROR); + $forked_composer_json_data = NestedArray::mergeDeep($original_composer_json_data, $package); + if ($original_composer_json_data === $forked_composer_json_data) { + throw new \LogicException(sprintf('Nothing is actually different in this fork of the package %s.', $package['name'])); + } + $package = $forked_composer_json_data; + $repo_path .= "--{$package['version']}"; + // Cannot create multiple forks with the same version. This is likely + // due to a test simulating a failed Stage::apply(). + if (!is_dir($repo_path)) { + $this->createPathRepo($package, $repo_path, $original_repo_path); + } + } + else { + $this->createPathRepo($package, $repo_path, NULL); + } + + // Add the package to the Composer repository defined for the site. + $packages_json = $this->dir . '/packages.json'; + $packages_data = file_get_contents($packages_json); + $packages_data = json_decode($packages_data, TRUE, flags: JSON_THROW_ON_ERROR); + + $version = $package['version']; + $package['dist'] = [ + 'type' => 'path', + 'url' => $repo_path, + ]; + $packages_data['packages'][$name][$version] = $package; + assert(file_put_contents($packages_json, json_encode($packages_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) !== FALSE); + + return $repo_path; + } + + /** + * Sets up the path repos at absolute paths. + */ + public function setUpRepos(): void { + $fs = new SymfonyFileSystem(); + $path_repo_base = \Drupal::state()->get(self::PATH_REPO_STATE_KEY); + if (empty($path_repo_base)) { + $path_repo_base = FileSystem::getOsTemporaryDirectory() . '/base-repo-' . microtime(TRUE) . rand(0, 10000); + \Drupal::state()->set(self::PATH_REPO_STATE_KEY, $path_repo_base); + // Copy the existing repos that were used to make the fixtures into the + // new folder. + $fs->mirror(__DIR__ . '/../../../fixtures/path_repos', $path_repo_base); + } + // Update all the repos in the composer.json file to point to the new + // repos at the absolute path. + $composer_json = file_get_contents($this->dir . '/packages.json'); + assert(file_put_contents($this->dir . '/packages.json', str_replace('../path_repos/', "$path_repo_base/", $composer_json)) !== FALSE); + $this->runComposerCommand(['update', '--lock']); + $this->runComposerCommand(['install']); + } + +} diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/ProcessFactory.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ProcessFactory.php new file mode 100644 index 00000000000..6c77f6a9279 --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/ProcessFactory.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\fixture_manipulator; + +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * Process factory that always sets the COMPOSER_MIRROR_PATH_REPOS env variable. + * + * This is necessary because the fake_site fixture is built from a Composer-type + * repository, which will normally try to symlink packages which are installed + * from local directories, which in turn will break Package Manager because it + * does not support symlinks pointing outside the main code base. The + * COMPOSER_MIRROR_PATH_REPOS environment variable forces Composer to mirror, + * rather than symlink, local directories when installing packages. + * + * @see \Drupal\fixture_manipulator\FixtureManipulator::setUpRepos() + */ +final class ProcessFactory implements ProcessFactoryInterface { + + /** + * Constructs a ProcessFactory object. + * + * @param \PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface $decorated + * The decorated process factory service. + */ + public function __construct(private readonly ProcessFactoryInterface $decorated) {} + + /** + * {@inheritdoc} + */ + public function create(array $command, ?PathInterface $cwd = NULL, array $env = []): ProcessInterface { + $process = $this->decorated->create($command, $cwd, $env); + + $env = $process->getEnv(); + $env['COMPOSER_MIRROR_PATH_REPOS'] = '1'; + $process->setEnv($env); + return $process; + } + +} diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php new file mode 100644 index 00000000000..b9fabada49e --- /dev/null +++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php @@ -0,0 +1,109 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\fixture_manipulator; + +use Drupal\Core\State\StateInterface; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * A fixture manipulator service that commits changes after begin. + */ +final class StageFixtureManipulator extends FixtureManipulator implements BeginnerInterface { + + /** + * The state key to use. + */ + private const STATE_KEY = __CLASS__ . 'MANIPULATOR_ARGUMENTS'; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private StateInterface $state; + + /** + * The decorated service. + * + * @var \PhpTuf\ComposerStager\API\Core\BeginnerInterface + */ + private BeginnerInterface $inner; + + /** + * Constructions a StageFixtureManipulator object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $inner + * The decorated beginner service. + */ + public function __construct(StateInterface $state, BeginnerInterface $inner) { + $this->state = $state; + $this->inner = $inner; + } + + /** + * {@inheritdoc} + */ + public function begin(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $this->inner->begin($activeDir, $stagingDir, $exclusions, $callback, $timeout); + if ($this->getQueuedManipulationItems()) { + $this->doCommitChanges($stagingDir->absolute()); + } + } + + /** + * {@inheritdoc} + */ + public function commitChanges(string $dir): void { + throw new \BadMethodCallException('::commitChanges() should not be called directly in StageFixtureManipulator().'); + } + + /** + * {@inheritdoc} + */ + public function __destruct() { + // Overrides `__destruct` because the staged fixture manipulator service + // will be destroyed after every request. + // @see \Drupal\fixture_manipulator\StageFixtureManipulator::handleTearDown() + } + + /** + * Handles test tear down to ensure all changes were committed. + */ + public static function handleTearDown(): void { + if (!empty(\Drupal::state()->get(self::STATE_KEY))) { + throw new \LogicException('The StageFixtureManipulator has arguments that were not cleared. This likely means that the PostCreateEvent was never fired.'); + } + } + + /** + * {@inheritdoc} + */ + protected function queueManipulation(string $method, array $arguments): void { + $stored_arguments = $this->getQueuedManipulationItems(); + $stored_arguments[$method][] = $arguments; + $this->state->set(self::STATE_KEY, $stored_arguments); + } + + /** + * {@inheritdoc} + */ + protected function clearQueuedManipulationItems(): void { + $this->state->delete(self::STATE_KEY); + } + + /** + * {@inheritdoc} + */ + protected function getQueuedManipulationItems(): array { + return $this->state->get(self::STATE_KEY, []); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml b/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml new file mode 100644 index 00000000000..316afcbb45f --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml @@ -0,0 +1,7 @@ +name: 'Package Manager Bypass' +description: 'Mocks PathLocator service, decorates Beginner & Committer services (adds logging) and by default bypasses the Stager service (to minimize I/O during tests).' +type: module +package: Testing +dependencies: + - auto_updates:package_manager + - auto_updates:fixture_manipulator diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.services.yml b/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.services.yml new file mode 100644 index 00000000000..b5fea63ec72 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.services.yml @@ -0,0 +1,7 @@ +services: + _defaults: + autowire: true + Drupal\package_manager_bypass\LoggingBeginner: + decorates: 'PhpTuf\ComposerStager\API\Core\BeginnerInterface' + Drupal\package_manager_bypass\LoggingCommitter: + decorates: 'PhpTuf\ComposerStager\API\Core\CommitterInterface' diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php new file mode 100644 index 00000000000..efb8a10759f --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/ComposerStagerExceptionTrait.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +/** + * Trait to make Composer Stager throw pre-determined exceptions in tests. + * + * @internal + */ +trait ComposerStagerExceptionTrait { + + /** + * Sets an exception to be thrown. + * + * @param string|null $class + * The class of exception to throw, or NULL to delete a stored exception. + * @param mixed ...$arguments + * Arguments to pass to the exception constructor. + */ + public static function setException(?string $class = \Exception::class, mixed ...$arguments): void { + if ($class) { + \Drupal::state()->set(static::class . '-exception', func_get_args()); + } + else { + \Drupal::state()->delete(static::class . '-exception'); + } + } + + /** + * Throws the exception if set. + */ + private function throwExceptionIfSet(): void { + if ($exception = $this->state->get(static::class . '-exception')) { + $class = array_shift($exception); + throw new $class(...$exception); + } + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingBeginner.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingBeginner.php new file mode 100644 index 00000000000..6bb75ed5be9 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingBeginner.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\State\StateInterface; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * A composer-stager Beginner decorator that adds logging. + * + * @internal + */ +final class LoggingBeginner implements BeginnerInterface { + + use ComposerStagerExceptionTrait; + use LoggingDecoratorTrait; + + /** + * The decorated service. + * + * @var \PhpTuf\ComposerStager\API\Core\BeginnerInterface + */ + private $inner; + + /** + * Constructs a Beginner object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $inner + * The decorated beginner service. + */ + public function __construct(StateInterface $state, BeginnerInterface $inner) { + $this->state = $state; + $this->inner = $inner; + } + + /** + * {@inheritdoc} + */ + public function begin(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions?->getAll(), $timeout); + $this->throwExceptionIfSet(); + $this->inner->begin($activeDir, $stagingDir, $exclusions, $callback, $timeout); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingCommitter.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingCommitter.php new file mode 100644 index 00000000000..4d5e1e160ca --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingCommitter.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\State\StateInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * A composer-stager Committer decorator that adds logging. + * + * @internal + */ +final class LoggingCommitter implements CommitterInterface { + + use ComposerStagerExceptionTrait; + use LoggingDecoratorTrait; + + /** + * The decorated service. + * + * @var \PhpTuf\ComposerStager\API\Core\CommitterInterface + */ + private $inner; + + /** + * Constructs a Committer object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $inner + * The decorated committer service. + */ + public function __construct(StateInterface $state, CommitterInterface $inner) { + $this->state = $state; + $this->inner = $inner; + } + + /** + * {@inheritdoc} + */ + public function commit(PathInterface $stagingDir, PathInterface $activeDir, ?PathListInterface $exclusions = NULL, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $this->saveInvocationArguments($stagingDir, $activeDir, $exclusions?->getAll(), $timeout); + $this->throwExceptionIfSet(); + $this->inner->commit($stagingDir, $activeDir, $exclusions, $callback, $timeout); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingDecoratorTrait.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingDecoratorTrait.php new file mode 100644 index 00000000000..2bbbad5c774 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/LoggingDecoratorTrait.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +/** + * Records information about method invocations. + * + * This can be used by functional tests to ensure that the bypassed Composer + * Stager services were called as expected. Kernel and unit tests should use + * regular mocks instead. + * + * @internal + */ +trait LoggingDecoratorTrait { + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private $state; + + /** + * Returns the arguments from every invocation of the main class method. + * + * @return mixed[] + * The arguments from every invocation of the main class method. + */ + public function getInvocationArguments(): array { + return $this->state->get(static::class . ' arguments', []); + } + + /** + * Records the arguments from an invocation of the main class method. + * + * @param mixed ...$arguments + * The arguments that the main class method was called with. + */ + private function saveInvocationArguments(...$arguments): void { + $invocations = $this->getInvocationArguments(); + $invocations[] = $arguments; + $this->state->set(static::class . ' arguments', $invocations); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/MockPathLocator.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/MockPathLocator.php new file mode 100644 index 00000000000..dcdd6d262b6 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/MockPathLocator.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\PathLocator as BasePathLocator; +use Symfony\Component\Filesystem\Path; + +/** + * Mock path locator: allows specifying paths instead of discovering paths. + * + * @internal + */ +final class MockPathLocator extends BasePathLocator { + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private $state; + + /** + * Constructs a PathLocator object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + * @param mixed ...$arguments + * Additional arguments to pass to the parent constructor. + */ + public function __construct(StateInterface $state, ...$arguments) { + parent::__construct(...$arguments); + $this->state = $state; + } + + /** + * {@inheritdoc} + */ + public function getProjectRoot(): string { + $project_root = $this->state->get(static::class . ' root'); + if ($project_root === NULL) { + $project_root = $this->getVendorDirectory() . DIRECTORY_SEPARATOR . '..'; + $project_root = realpath($project_root); + } + return $project_root; + } + + /** + * {@inheritdoc} + */ + public function getVendorDirectory(): string { + return $this->state->get(static::class . ' vendor', parent::getVendorDirectory()); + } + + /** + * {@inheritdoc} + */ + public function getWebRoot(): string { + return $this->state->get(static::class . ' web', parent::getWebRoot()); + } + + /** + * {@inheritdoc} + */ + public function getStagingRoot(): string { + return $this->state->get(static::class . ' stage', parent::getStagingRoot()); + } + + /** + * Sets the paths to return. + * + * @param string|null $project_root + * The project root, or NULL to defer to the parent class. + * @param string|null $vendor_dir + * The vendor directory, or NULL to defer to the parent class. + * @param string|null $web_root + * The web root, relative to the project root, or NULL to defer to the + * parent class. + * @param string|null $staging_root + * The absolute path of the stage root directory, or NULL to defer to the + * parent class. + */ + public function setPaths(?string $project_root, ?string $vendor_dir, ?string $web_root, ?string $staging_root): void { + foreach ([$project_root, $staging_root] as $path) { + if (!empty($path) && !Path::isAbsolute($path)) { + throw new \InvalidArgumentException('project_root and staging_root need to be absolute paths.'); + } + } + $this->state->set(static::class . ' root', is_null($project_root) ? NULL : realpath($project_root)); + $this->state->set(static::class . ' vendor', is_null($vendor_dir) ? NULL : realpath($vendor_dir)); + $this->state->set(static::class . ' web', is_null($web_root) ? NULL : Path::canonicalize($web_root)); + $this->state->set(static::class . ' stage', is_null($staging_root) ? NULL : realpath($staging_root)); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/NoOpStager.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/NoOpStager.php new file mode 100644 index 00000000000..fd15f9fbc51 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/NoOpStager.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Composer\Json\JsonFile; +use Drupal\Core\State\StateInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Service\ProcessInterface; + +/** + * A composer-stager Stager implementation that does nothing, except logging. + * + * By default, it will modify composer.lock in the stage directory, to fool the + * \Drupal\package_manager\Validator\LockFileValidator into thinking that there + * are pending composer operations. + * + * Opt out of this by calling @code setLockFileShouldChange(FALSE) @endcode. + * + * @see ::setLockFileShouldChange() + * @see \Drupal\package_manager\Validator\LockFileValidator + * + * @internal + */ +final class NoOpStager implements StagerInterface { + + use ComposerStagerExceptionTrait; + use LoggingDecoratorTrait; + + /** + * Constructs a Stager object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function __construct(StateInterface $state) { + $this->state = $state; + } + + /** + * {@inheritdoc} + */ + public function stage(array $composerCommand, PathInterface $activeDir, PathInterface $stagingDir, ?OutputCallbackInterface $callback = NULL, ?int $timeout = ProcessInterface::DEFAULT_TIMEOUT): void { + $this->saveInvocationArguments($composerCommand, $stagingDir, $timeout); + $this->throwExceptionIfSet(); + + // If desired, simulate a change to the lock file (e.g., as a result of + // running `composer update`). + $lockFile = new JsonFile($stagingDir->absolute() . '/composer.lock'); + $changeLockFile = $this->state->get(static::class . ' lock', TRUE); + + if ($changeLockFile && $lockFile->exists()) { + $data = $lockFile->read(); + $data['_time'] = microtime(); + $lockFile->write($data); + } + } + + /** + * Sets whether ::stage() should simulate a change in the lock file. + * + * @param bool $value + * (optional) Whether to simulate a change in the lock file when + * ::stage() is called. Defaults to TRUE. + */ + public static function setLockFileShouldChange(bool $value = TRUE): void { + \Drupal::state()->set(static::class . ' lock', $value); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php new file mode 100644 index 00000000000..38a55eac4e6 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php @@ -0,0 +1,50 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_bypass; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Drupal\Core\Site\Settings; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use Symfony\Component\DependencyInjection\Parameter; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Defines services to bypass Package Manager's core functionality. + * + * @internal + */ +final class PackageManagerBypassServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container): void { + parent::alter($container); + + // By default, \Drupal\package_manager_bypass\NoOpStager is applied, except + // when a test opts out by setting this setting to FALSE. + // @see \Drupal\package_manager_bypass\NoOpStager::setLockFileShouldChange() + if (Settings::get('package_manager_bypass_composer_stager', TRUE)) { + $container->register(NoOpStager::class) + ->setClass(NoOpStager::class) + ->setPublic(FALSE) + ->setAutowired(TRUE) + ->setDecoratedService(StagerInterface::class); + } + + $container->getDefinition(PathLocator::class) + ->setClass(MockPathLocator::class) + ->setAutowired(FALSE) + ->setArguments([ + new Reference('state'), + new Parameter('app.root'), + new Reference('config.factory'), + new Reference('file_system'), + ]); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml new file mode 100644 index 00000000000..6a64061f1ce --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Test API' +description: 'Provides API endpoints for doing stage operations in functional tests.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml new file mode 100644 index 00000000000..7911b8c057a --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml @@ -0,0 +1,21 @@ +package_manager_test_api: + path: '/package-manager-test-api' + defaults: + _controller: 'Drupal\package_manager_test_api\ApiController::run' + requirements: + _access: 'TRUE' +package_manager_test_api.finish: + path: '/package-manager-test-api/finish/{id}' + defaults: + _controller: 'Drupal\package_manager_test_api\ApiController::finish' + requirements: + _access: 'TRUE' +package_manager_test_api.check_setup: + path: '/package-manager-test-api/check-setup' + defaults: + _controller: 'Drupal\package_manager_test_api\ApiController::checkSetup' + requirements: + _access: 'TRUE' + options: + _maintenance_access: TRUE + no_cache: TRUE diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php new file mode 100644 index 00000000000..d2839be3947 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_api; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\Url; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StageBase; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * Provides API endpoints to interact with a stage directory in functional test. + */ +class ApiController extends ControllerBase { + + /** + * The route to redirect to after the stage has been applied. + * + * @var string + */ + protected $finishedRoute = 'package_manager_test_api.finish'; + + public function __construct(protected StageBase $stage) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + $stage = new ControllerStage( + $container->get(PathLocator::class), + $container->get(BeginnerInterface::class), + $container->get(StagerInterface::class), + $container->get(CommitterInterface::class), + $container->get(QueueFactory::class), + $container->get('event_dispatcher'), + $container->get('tempstore.shared'), + $container->get('datetime.time'), + $container->get(PathFactoryInterface::class), + $container->get(FailureMarker::class), + ); + return new static($stage); + } + + /** + * Begins a stage life cycle. + * + * Creates a stage directory, requires packages into it, applies changes to + * the active directory. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. The runtime and dev dependencies are expected to be in + * either the query string or request body, under the 'runtime' and 'dev' + * keys, respectively. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse + * A response that directs to the ::finish() method. + * + * @see ::finish() + */ + public function run(Request $request): RedirectResponse { + $id = $this->createAndApplyStage($request); + $redirect_url = Url::fromRoute($this->finishedRoute) + ->setRouteParameter('id', $id) + ->setAbsolute() + ->toString(); + + return new RedirectResponse($redirect_url); + } + + /** + * Performs post-apply tasks and destroys the stage. + * + * @param string $id + * The stage ID. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function finish(string $id): Response { + $this->stage->claim($id)->postApply(); + $this->stage->destroy(); + return new Response(); + } + + /** + * Creates a stage, requires packages into it, and applies the changes. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request. The runtime and dev dependencies are expected to be in + * either the query string or request body, under the 'runtime' and 'dev' + * keys, respectively. + * + * @return string + * Unique ID for the stage, which can be used to claim the stage before + * performing other operations on it. Calling code should store this ID for + * as long as the stage needs to exist. + */ + protected function createAndApplyStage(Request $request) : string { + $id = $this->stage->create(); + $this->stage->require( + $request->get('runtime', []), + $request->get('dev', []) + ); + $this->stage->apply(); + return $id; + } + + /** + * Returns the information about current PHP server used for build tests. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + public function checkSetup(): Response { + return new Response( + 'max_execution_time=' . ini_get('max_execution_time') . + ':set_time_limit-exists=' . (function_exists('set_time_limit') ? 'yes' : 'no') + ); + } + +} + +/** + * Non-abstract version of StageBase. + * + * This is needed because we cannot instantiate StageBase as it's abstract, and + * we also can't use anonymous class because the name of anonymous class is + * always unique for every request which will create problem while claiming the + * stage as the stored lock will be different from current lock. + * + * @see \Drupal\package_manager\StageBase::claim() + */ +final class ControllerStage extends StageBase { + + /** + * {@inheritdoc} + */ + protected string $type = 'package_manager_test_api:controller'; + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.info.yml new file mode 100644 index 00000000000..2366f4585f3 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Test Event Logger' +description: 'Provides an event subscriber to test logging during events in Package Manager' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.services.yml new file mode 100644 index 00000000000..408eba84e49 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/package_manager_test_event_logger.services.yml @@ -0,0 +1,5 @@ +services: + package_manager_test_event_logger.subscriber: + class: Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber + tags: + - { name: event_subscriber } diff --git a/core/modules/package_manager/tests/modules/package_manager_test_event_logger/src/EventSubscriber/EventLogSubscriber.php b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/src/EventSubscriber/EventLogSubscriber.php new file mode 100644 index 00000000000..295394ae632 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_event_logger/src/EventSubscriber/EventLogSubscriber.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_event_logger\EventSubscriber; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an event subscriber to test logging during events in Package Manager. + */ +final class EventLogSubscriber implements EventSubscriberInterface { + + /** + * The name of the log file to write to. + * + * @var string + */ + public const LOG_FILE_NAME = 'package_manager_test_event.log'; + + /** + * Excludes the log file from Package Manager operations. + * + * @param \Drupal\package_manager\Event\CollectPathsToExcludeEvent $event + * The event being handled. + */ + public function excludeLogFile(CollectPathsToExcludeEvent $event): void { + $event->addPathsRelativeToProjectRoot([self::LOG_FILE_NAME]); + } + + /** + * Logs all events in the stage life cycle. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function logEventInfo(StageEvent $event): void { + $log_file = \Drupal::service(PathLocator::class)->getProjectRoot() . '/' . self::LOG_FILE_NAME; + + if (file_exists($log_file)) { + $log_data = file_get_contents($log_file); + $log_data = json_decode($log_data, TRUE, flags: JSON_THROW_ON_ERROR); + } + else { + $log_data = []; + } + + $log_data[] = [ + 'event' => $event::class, + 'stage' => $event->stage::class, + ]; + file_put_contents($log_file, json_encode($log_data, JSON_UNESCAPED_SLASHES)); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + // This subscriber should run before every other validator, because the + // purpose of this subscriber is to log all dispatched events. + // @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator + // @see \Drupal\package_manager\Validator\BaseRequirementValidatorTrait + // @see \Drupal\package_manager\Validator\EnvironmentSupportValidator + return [ + CollectPathsToExcludeEvent::class => ['excludeLogFile'], + PreCreateEvent::class => ['logEventInfo', PHP_INT_MAX], + PostCreateEvent::class => ['logEventInfo', PHP_INT_MAX], + PreRequireEvent::class => ['logEventInfo', PHP_INT_MAX], + PostRequireEvent::class => ['logEventInfo', PHP_INT_MAX], + PreApplyEvent::class => ['logEventInfo', PHP_INT_MAX], + PostApplyEvent::class => ['logEventInfo', PHP_INT_MAX], + ]; + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml new file mode 100644 index 00000000000..ecf8c8e6122 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml @@ -0,0 +1,7 @@ +name: 'Package Manager Test Release history' +type: module +description: 'Provides a mechanism for serving fake release history metadata in functional tests.' +package: Testing +dependencies: + - drupal:update + - drupal:update_test diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml new file mode 100644 index 00000000000..df0b40c6178 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml @@ -0,0 +1,9 @@ +package_manager_test_release_history.metadata: + path: '/test-release-history/{project_name}/{version}' + defaults: + _title: 'Update test' + _controller: '\Drupal\package_manager_test_release_history\TestController::metadata' + requirements: + _access: 'TRUE' + options: + _maintenance_access: TRUE diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php b/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php new file mode 100644 index 00000000000..1c99bb6e192 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_release_history; + +use Drupal\Core\Controller\ControllerBase; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Response; + +class TestController extends ControllerBase { + + /** + * Page callback: Prints mock XML for the Update Manager module. + * + * @todo This is a wholesale copy of + * \Drupal\update_test\Controller\UpdateTestController::updateTest() for + * testing package_manager. This was done in order to use a different + * directory of mock XML files. Remove this module in + * https://drupal.org/i/3274826. + */ + public function metadata($project_name = 'drupal', $version = NULL): Response { + $xml_map = $this->config('update_test.settings')->get('xml_map'); + if (isset($xml_map[$project_name])) { + $file = $xml_map[$project_name]; + } + elseif (isset($xml_map['#all'])) { + $file = $xml_map['#all']; + } + else { + // The test didn't specify, for example, the webroot has other modules and + // themes installed but they're disabled by the version of the site + // running the test. So, we default to a file we know won't exist, so at + // least we'll get an empty xml response instead of a bunch of Drupal page + // output. + $file = '#broken#'; + } + + $headers = ['Content-Type' => 'text/xml; charset=utf-8']; + if (!is_file($file)) { + // Return an empty response. + return new Response('', 200, $headers); + } + return new BinaryFileResponse($file, 200, $headers); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_update/package_manager_test_update.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_update/package_manager_test_update.info.yml new file mode 100644 index 00000000000..c245910b786 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_update/package_manager_test_update.info.yml @@ -0,0 +1,4 @@ +name: 'Package Manager Test Update' +description: 'A module to test updates' +type: module +package: Testing diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml new file mode 100644 index 00000000000..3558b4cd3c8 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml @@ -0,0 +1,6 @@ +name: 'Package Manager Validation Test' +description: 'Provides an event subscriber to test Package Manager validation.' +type: module +package: Testing +dependencies: + - auto_updates:package_manager diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml new file mode 100644 index 00000000000..bd41327d315 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml @@ -0,0 +1,12 @@ +services: + package_manager_test_validation.subscriber: + class: Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber + arguments: + - '@state' + tags: + - { name: event_subscriber } + package_manager.validator.collect_paths_to_exclude_fail: + class: Drupal\package_manager_test_validation\CollectPathsToExcludeFailValidator + autowire: true + tags: + - { name: event_subscriber } diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/CollectPathsToExcludeFailValidator.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/CollectPathsToExcludeFailValidator.php new file mode 100644 index 00000000000..3a920d927f3 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/CollectPathsToExcludeFailValidator.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_validation; + +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\PathLocator; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Allows to test an excluder which fails on CollectPathsToExcludeEvent. + */ +class CollectPathsToExcludeFailValidator implements EventSubscriberInterface { + + /** + * Constructs a CollectPathsToExcludeFailValidator object. + * + * @param \Drupal\package_manager\ComposerInspector $composerInspector + * The Composer inspector service. + * @param \Drupal\package_manager\PathLocator $pathLocator + * The path locator service. + */ + public function __construct( + private readonly ComposerInspector $composerInspector, + private readonly PathLocator $pathLocator, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + CollectPathsToExcludeEvent::class => 'callToComposer', + ]; + } + + /** + * Fails when composer.json is deleted to simulate failure on excluders. + */ + public function callToComposer(): void { + $this->composerInspector->validate($this->pathLocator->getProjectRoot()); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php new file mode 100644 index 00000000000..53e98202ab7 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php @@ -0,0 +1,177 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_validation\EventSubscriber; + +use Drupal\Core\State\StateInterface; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines an event subscriber for testing validation of Package Manager events. + */ +class TestSubscriber implements EventSubscriberInterface { + + /** + * The key to use store the test results. + * + * @var string + */ + protected const STATE_KEY = 'package_manager_test_validation'; + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + protected $state; + + /** + * Creates a TestSubscriber object. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function __construct(StateInterface $state) { + $this->state = $state; + } + + /** + * Sets whether a specific event will call exit(). + * + * This is useful for simulating an unrecoverable (fatal) error when handling + * the given event. + * + * @param string $event + * The event class. + */ + public static function setExit(string $event): void { + \Drupal::state()->set(self::getStateKey($event), 'exit'); + } + + /** + * Sets validation results for a specific event. + * + * This method is static to enable setting the expected results before this + * module is enabled. + * + * @param \Drupal\package_manager\ValidationResult[]|null $results + * The validation results, or NULL to delete stored results. + * @param string $event + * The event class. + */ + public static function setTestResult(?array $results, string $event): void { + $key = static::getStateKey($event); + + $state = \Drupal::state(); + if (isset($results)) { + $state->set($key, $results); + } + else { + $state->delete($key); + } + } + + /** + * Sets an exception to throw for a specific event. + * + * This method is static to enable setting the expected results before this + * module is enabled. + * + * @param \Throwable|null $error + * The exception to throw, or NULL to delete a stored exception. + * @param string $event + * The event class. + */ + public static function setException(?\Throwable $error, string $event): void { + $key = self::getStateKey($event); + + $state = \Drupal::state(); + if (isset($error)) { + $state->set($key, $error); + } + else { + $state->delete($key); + } + } + + /** + * Computes the state key to use for a given event class. + * + * @param string $event + * The event class. + * + * @return string + * The state key under which to store the results for the given event. + */ + protected static function getStateKey(string $event): string { + $key = hash('sha256', static::class . $event); + return static::STATE_KEY . substr($key, 0, 8); + } + + /** + * Adds validation results to a stage event. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function handleEvent(StageEvent $event): void { + $results = $this->state->get(self::getStateKey(get_class($event)), []); + + // Record that value of maintenance mode for each event. + $this->state->set(get_class($event) . '.' . 'system.maintenance_mode', $this->state->get('system.maintenance_mode')); + + if ($results instanceof \Throwable) { + throw $results; + } + elseif ($results === 'exit') { + exit(); + } + elseif (is_string($results)) { + \Drupal::messenger()->addStatus($results); + return; + } + /** @var \Drupal\package_manager\ValidationResult $result */ + foreach ($results as $result) { + $event->addResult($result); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => ['handleEvent', 5], + PostCreateEvent::class => ['handleEvent', 5], + PreRequireEvent::class => ['handleEvent', 5], + PostRequireEvent::class => ['handleEvent', 5], + PreApplyEvent::class => ['handleEvent', 5], + PostApplyEvent::class => ['handleEvent', 5], + StatusCheckEvent::class => ['handleEvent', 5], + ]; + } + + /** + * Sets a status message that will be sent to the messenger for an event. + * + * @param string $message + * Message text. + * @param string $event + * The event class. + */ + public static function setMessage(string $message, string $event): void { + $key = static::getStateKey($event); + $state = \Drupal::state(); + $state->set($key, $message); + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php new file mode 100644 index 00000000000..dd8fea39a4d --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_validation; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\DependencyInjection\ServiceProviderBase; +use Drupal\package_manager\Validator\StagedDBUpdateValidator; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Modifies container services for testing. + */ +class PackageManagerTestValidationServiceProvider extends ServiceProviderBase { + + /** + * {@inheritdoc} + */ + public function alter(ContainerBuilder $container): void { + parent::alter($container); + + $service_id = StagedDBUpdateValidator::class; + if ($container->hasDefinition($service_id)) { + $container->getDefinition($service_id) + ->setClass(StagedDatabaseUpdateValidator::class) + ->addMethodCall('setState', [ + new Reference('state'), + ]); + } + } + +} diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php new file mode 100644 index 00000000000..cbab1d42b07 --- /dev/null +++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\package_manager_test_validation; + +use Drupal\package_manager\Validator\StagedDBUpdateValidator as BaseValidator; +use Drupal\Core\Extension\Extension; +use Drupal\Core\State\StateInterface; + +/** + * Allows tests to dictate which extensions have staged database updates. + */ +class StagedDatabaseUpdateValidator extends BaseValidator { + + /** + * The state service. + * + * @var \Drupal\Core\State\StateInterface + */ + private $state; + + /** + * Sets the state service dependency. + * + * @param \Drupal\Core\State\StateInterface $state + * The state service. + */ + public function setState(StateInterface $state): void { + $this->state = $state; + } + + /** + * Sets the names of the extensions which should have staged database updates. + * + * @param string[]|null $extensions + * The machine names of the extensions which should say they have staged + * database updates, or NULL to defer to the parent class. + */ + public static function setExtensionsWithUpdates(?array $extensions): void { + \Drupal::state()->set(static::class, $extensions); + } + + /** + * {@inheritdoc} + */ + public function hasStagedUpdates(string $stage_dir, Extension $extension): bool { + $extensions = $this->state->get(static::class); + if (isset($extensions)) { + return in_array($extension->getName(), $extensions, TRUE); + } + return parent::hasStagedUpdates($stage_dir, $extension); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php new file mode 100644 index 00000000000..dd9f653133e --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Build; + +/** + * Tests installing packages in a stage directory. + * + * @group package_manager + * @internal + */ +class PackageInstallTest extends TemplateProjectTestBase { + + /** + * Tests installing packages in a stage directory. + */ + public function testPackageInstall(): void { + $this->createTestProject('RecommendedProject'); + + $this->setReleaseMetadata([ + 'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml', + ]); + $this->addRepository('alpha', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/build_test_projects/alpha/1.0.0')); + // Repository definitions affect the lock file hash, so update the hash to + // ensure that Composer won't complain that the lock file is out of sync. + $this->runComposer('composer update --lock', 'project'); + + // Use the API endpoint to create a stage and install alpha 1.0.0. + $this->makePackageManagerTestApiRequest( + '/package-manager-test-api', + [ + 'runtime' => [ + 'drupal/alpha:1.0.0', + ], + ] + ); + // Assert the module was installed. + $this->assertFileEquals( + __DIR__ . '/../../fixtures/build_test_projects/alpha/1.0.0/composer.json', + $this->getWebRoot() . '/modules/contrib/alpha/composer.json', + ); + $this->assertRequestedChangesWereLogged(['Install drupal/alpha 1.0.0']); + $this->assertAppliedChangesWereLogged(['Installed drupal/alpha 1.0.0']); + } + + /** + * Tests installing a Drupal submodule. + * + * This test installs a submodule using a set-up that mimics how + * packages.drupal.org handles submodules. Submodules are Composer + * metapackages which depend on the Composer package of the main module. + */ + public function testSubModules(): void { + $this->createTestProject('RecommendedProject'); + // Set up the release metadata for the main module. The submodule does not + // have its own release metadata. + $this->setReleaseMetadata([ + 'main_module' => __DIR__ . '/../../fixtures/release-history/main_module.1.0.0.xml', + ]); + + // Add repositories for Drupal modules which will contain the code for its + // submodule also. + $this->addRepository('main_module', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/build_test_projects/main_module')); + + // Add a repository for the submodule of 'main_module'. Submodule + // repositories are metapackages which have no code of their own but that + // require the main module. + $this->addRepository('main_module_submodule', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/path_repos/main_module_submodule')); + + // Repository definitions affect the lock file hash, so update the hash to + // ensure that Composer won't complain that the lock file is out of sync. + $this->runComposer('composer update --lock', 'project'); + + $this->makePackageManagerTestApiRequest( + '/package-manager-test-api', + [ + 'runtime' => [ + 'drupal/main_module_submodule:1.0.0', + ], + ] + ); + + // Assert main_module and the submodule were installed. + $main_module_path = $this->getWebRoot() . '/modules/contrib/main_module'; + $this->assertFileExists("$main_module_path/main_module.info.yml"); + $this->assertFileExists("$main_module_path/main_module_submodule/main_module_submodule.info.yml"); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php new file mode 100644 index 00000000000..3202b3eb2e0 --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Build; + +use Drupal\package_manager_test_api\ControllerStage; + +/** + * Tests updating packages in a stage directory. + * + * @group package_manager + * @internal + */ +class PackageUpdateTest extends TemplateProjectTestBase { + + /** + * Tests updating packages in a stage directory. + */ + public function testPackageUpdate(): void { + $this->createTestProject('RecommendedProject'); + + $fixtures = __DIR__ . '/../../fixtures/build_test_projects'; + + $alpha_repo_path = $this->copyFixtureToTempDirectory("$fixtures/alpha/1.0.0"); + $this->addRepository('alpha', $alpha_repo_path); + $updated_module_repo_path = $this->copyFixtureToTempDirectory("$fixtures/updated_module/1.0.0"); + $this->addRepository('updated_module', $updated_module_repo_path); + $this->setReleaseMetadata([ + 'updated_module' => __DIR__ . '/../../fixtures/release-history/updated_module.1.1.0.xml', + ]); + $this->runComposer('composer require drupal/alpha drupal/updated_module --update-with-all-dependencies', 'project'); + + // The updated_module provides actual Drupal-facing functionality that we're + // testing as well, so we need to install it. + $this->installModules(['updated_module']); + + // Change both modules' upstream version. + static::copyFixtureFilesTo("$fixtures/alpha/1.1.0", $alpha_repo_path); + static::copyFixtureFilesTo("$fixtures/updated_module/1.1.0", $updated_module_repo_path); + // Make .git folder + + // Use the API endpoint to create a stage and update updated_module to + // 1.1.0. Even though both modules have version 1.1.0 available, only + // updated_module should be updated. + $this->makePackageManagerTestApiRequest( + '/package-manager-test-api', + [ + 'runtime' => [ + 'drupal/updated_module:1.1.0', + ], + ] + ); + + $expected_versions = [ + 'alpha' => '1.0.0', + 'updated_module' => '1.1.0', + ]; + foreach ($expected_versions as $module_name => $expected_version) { + $path = "/modules/contrib/$module_name/composer.json"; + $module_composer_json = json_decode(file_get_contents($this->getWebRoot() . "/$path")); + $this->assertSame($expected_version, $module_composer_json?->version); + } + // The post-apply event subscriber in updated_module 1.1.0 should have + // created this file. + // @see \Drupal\updated_module\PostApplySubscriber::postApply() + $this->assertSame('Bravo!', file_get_contents($this->getWorkspaceDirectory() . '/project/bravo.txt')); + + $this->assertExpectedStageEventsFired(ControllerStage::class); + $this->assertRequestedChangesWereLogged(['Update drupal/updated_module from 1.0.0 to 1.1.0']); + $this->assertAppliedChangesWereLogged(['Updated drupal/updated_module from 1.0.0 to 1.1.0']); + } + +} diff --git a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php new file mode 100644 index 00000000000..c2aede3beee --- /dev/null +++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php @@ -0,0 +1,733 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Build; + +use Composer\Autoload\ClassLoader; +use Composer\InstalledVersions; +use Drupal\BuildTests\QuickStart\QuickStartTestBase; +use Drupal\Composer\Composer; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber; +use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; +use Drupal\Tests\RandomGeneratorTrait; +use Symfony\Component\Finder\Finder; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; + +/** + * Base class for tests which create a test site from a core project template. + * + * The test site will be created from one of the core Composer project templates + * (drupal/recommended-project or drupal/legacy-project) and contain complete + * copies of Drupal core and all installed dependencies, completely independent + * of the currently running code base. + * + * @internal + */ +abstract class TemplateProjectTestBase extends QuickStartTestBase { + + use AssertPreconditionsTrait; + use FixtureUtilityTrait; + use RandomGeneratorTrait; + + /** + * The web root of the test site, relative to the workspace directory. + * + * @var string + */ + private string $webRoot; + + /** + * A secondary server instance, to serve XML metadata about available updates. + * + * @var \Symfony\Component\Process\Process + */ + private Process $metadataServer; + + /** + * All output that the PHP web server logs to the error buffer. + * + * @var string + */ + private string $serverErrorLog = ''; + + /** + * The PHP web server's max_execution_time value. + * + * @var int + */ + protected const MAX_EXECUTION_TIME = 20; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // Build tests cannot be run if SQLite minimum version is not met. + $minimum_version = Tasks::SQLITE_MINIMUM_VERSION; + $actual_version = (new \PDO('sqlite::memory:')) + ->query('select sqlite_version()') + ->fetch()[0]; + if (version_compare($actual_version, $minimum_version, '<')) { + $this->markTestSkipped("SQLite version $minimum_version or later is required, but $actual_version was detected."); + } + + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + $this->metadataServer?->stop(); + parent::tearDown(); + } + + /** + * Data provider for tests which use all the core project templates. + * + * @return string[][] + * The test cases. + */ + public static function providerTemplate(): array { + return [ + 'RecommendedProject' => ['RecommendedProject'], + 'LegacyProject' => ['LegacyProject'], + ]; + } + + /** + * Sets the version of Drupal core to which the test site will be updated. + * + * @param string $version + * The Drupal core version to set. + */ + protected function setUpstreamCoreVersion(string $version): void { + $this->createVendorRepository([ + 'drupal/core' => $version, + 'drupal/core-dev' => $version, + 'drupal/core-dev-pinned' => $version, + 'drupal/core-recommended' => $version, + 'drupal/core-composer-scaffold' => $version, + 'drupal/core-project-message' => $version, + 'drupal/core-vendor-hardening' => $version, + ]); + + // Change the \Drupal::VERSION constant and put placeholder text in the + // README so we can ensure that we really updated to the correct version. We + // also change the default site configuration files so we can ensure that + // these are updated as well, despite `sites/default` being write-protected. + // @see ::assertUpdateSuccessful() + // @see ::createTestProject() + $core_dir = $this->getWorkspaceDrupalRoot() . '/core'; + Composer::setDrupalVersion($this->getWorkspaceDrupalRoot(), $version); + file_put_contents("$core_dir/README.txt", "Placeholder for Drupal core $version."); + + foreach (['default.settings.php', 'default.services.yml'] as $file) { + file_put_contents("$core_dir/assets/scaffold/files/$file", "# This is part of Drupal $version.\n", FILE_APPEND); + } + } + + /** + * Returns the full path to the test site's document root. + * + * @return string + * The full path of the test site's document root. + */ + protected function getWebRoot(): string { + return $this->getWorkspaceDirectory() . '/' . $this->webRoot; + } + + /** + * {@inheritdoc} + */ + protected function instantiateServer($port, $working_dir = NULL) { + $working_dir = $working_dir ?: $this->webRoot; + $finder = new PhpExecutableFinder(); + $working_path = $this->getWorkingPath($working_dir); + $server = [ + $finder->find(), + '-S', + '127.0.0.1:' . $port, + '-d max_execution_time=' . static::MAX_EXECUTION_TIME, + '-d disable_functions=set_time_limit', + '-t', + $working_path, + ]; + if (file_exists($working_path . DIRECTORY_SEPARATOR . '.ht.router.php')) { + $server[] = $working_path . DIRECTORY_SEPARATOR . '.ht.router.php'; + } + $ps = new Process($server, $working_path); + $ps->setIdleTimeout(30) + ->setTimeout(30) + ->start(function ($output_type, $output): void { + if ($output_type === Process::ERR) { + $this->serverErrorLog .= $output; + } + }); + // Wait until the web server has started. It is started if the port is no + // longer available. + for ($i = 0; $i < 50; $i++) { + usleep(100000); + if (!$this->checkPortIsAvailable($port)) { + return $ps; + } + } + + throw new \RuntimeException(sprintf("Unable to start the web server.\nCMD: %s \nCODE: %d\nSTATUS: %s\nOUTPUT:\n%s\n\nERROR OUTPUT:\n%s", $ps->getCommandLine(), $ps->getExitCode(), $ps->getStatus(), $ps->getOutput(), $ps->getErrorOutput())); + } + + /** + * {@inheritdoc} + */ + public function installQuickStart($profile, $working_dir = NULL): void { + parent::installQuickStart("$profile --no-ansi", $working_dir ?: $this->webRoot); + + // Always allow test modules to be installed in the UI and, for easier + // debugging, always display errors in their dubious glory. + $php = <<<END +\$settings['extension_discovery_scan_tests'] = TRUE; +\$config['system.logging']['error_level'] = 'verbose'; +END; + $this->writeSettings($php); + } + + /** + * {@inheritdoc} + */ + public function visit($request_uri = '/', $working_dir = NULL) { + return parent::visit($request_uri, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function formLogin($username, $password, $working_dir = NULL): void { + parent::formLogin($username, $password, $working_dir ?: $this->webRoot); + } + + /** + * Adds a path repository to the test site. + * + * @param string $name + * An arbitrary name for the repository. + * @param string $path + * The path of the repository. Must exist in the file system. + * @param string $working_directory + * (optional) The Composer working directory. Defaults to 'project'. + */ + protected function addRepository(string $name, string $path, $working_directory = 'project'): void { + $this->assertDirectoryExists($path); + + $repository = json_encode([ + 'type' => 'path', + 'url' => $path, + 'options' => [ + 'symlink' => FALSE, + ], + ], JSON_UNESCAPED_SLASHES); + $this->runComposer("composer config repo.$name '$repository'", $working_directory); + } + + /** + * Prepares the test site to serve an XML feed of available release metadata. + * + * @param array $xml_map + * The update XML map, as used by update_test.settings. + * + * @see \Drupal\package_manager_test_release_history\TestController::metadata() + */ + protected function setReleaseMetadata(array $xml_map): void { + foreach ($xml_map as $metadata_file) { + $this->assertFileIsReadable($metadata_file); + } + $xml_map = var_export($xml_map, TRUE); + $this->writeSettings("\$config['update_test.settings']['xml_map'] = $xml_map;"); + } + + /** + * Creates a test project from a given template and installs Drupal. + * + * @param string $template + * The template to use. Can be 'RecommendedProject' or 'LegacyProject'. + */ + protected function createTestProject(string $template): void { + // Create a copy of core (including its Composer plugins, templates, and + // metapackages) which we can modify. + $this->copyCodebase(); + + $workspace_dir = $this->getWorkspaceDirectory(); + $project_dir = $workspace_dir . '/project'; + mkdir($project_dir); + + $data = file_get_contents("$workspace_dir/composer/Template/$template/composer.json"); + $data = json_decode($data, TRUE, flags: JSON_THROW_ON_ERROR); + + // Allow pre-release versions of dependencies. + $data['minimum-stability'] = 'dev'; + + // Remove all repositories and replace them with a single local one that + // provides all dependencies. + $data['repositories'] = [ + 'vendor' => [ + 'type' => 'composer', + 'url' => 'file://' . $workspace_dir . '/vendor.json', + ], + // Disable Packagist entirely so that we don't test the Internet. + 'packagist.org' => FALSE, + ]; + + // Allow any version of the Drupal core packages in the template project. + self::unboundCoreConstraints($data['require']); + self::unboundCoreConstraints($data['require-dev']); + + // Do not run development Composer plugin, since it tries to run an + // executable that might not exist while dependencies are being installed. + // It adds no value to this test. + $data['config']['allow-plugins']['dealerdirect/phpcodesniffer-composer-installer'] = FALSE; + + // Always force Composer to mirror path repositories. This is necessary + // because dependencies are installed from a Composer-type repository, which + // will normally try to symlink packages which are installed from local + // directories. This breaks Package Manager, because it does not support + // symlinks pointing outside the main code base. + $script = '@putenv COMPOSER_MIRROR_PATH_REPOS=1'; + $data['scripts']['pre-install-cmd'] = $script; + $data['scripts']['pre-update-cmd'] = $script; + + file_put_contents($project_dir . '/composer.json', json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + + // Because we set the COMPOSER_MIRROR_PATH_REPOS=1 environment variable when + // creating the project, none of the dependencies should be symlinked. + $this->assertStringNotContainsString('Symlinking', $this->runComposer('composer install', 'project')); + + // If using the drupal/recommended-project template, we don't expect there + // to be an .htaccess file at the project root. One would normally be + // generated by Composer when Package Manager or other code creates a + // ComposerInspector object in the active directory, except that Package + // Manager takes specific steps to prevent that. So, here we're just + // confirming that, in fact, Composer's .htaccess protection was disabled. + // We don't do this for the drupal/legacy-project template because its + // project root, which is also the document root, SHOULD contain a .htaccess + // generated by Drupal core. + // We do this check because this test uses PHP's built-in web server, which + // ignores .htaccess files and everything in them, so a Composer-generated + // .htaccess file won't cause this test to fail. + if ($template === 'RecommendedProject') { + $this->assertFileDoesNotExist("$workspace_dir/project/.htaccess"); + } + + // Now that we know the project was created successfully, we can set the + // web root with confidence. + $this->webRoot = 'project/' . $data['extra']['drupal-scaffold']['locations']['web-root']; + + // Install Drupal. + $this->installQuickStart('standard'); + $this->formLogin($this->adminUsername, $this->adminPassword); + + // When checking for updates, we need to be able to make sub-requests, but + // the built-in PHP server is single-threaded. Therefore, open a second + // server instance on another port, which will serve the metadata about + // available updates. + $port = $this->findAvailablePort(); + $this->metadataServer = $this->instantiateServer($port); + $code = <<<END +\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/test-release-history'; +END; + + // Ensure Package Manager logs Composer Stager's process output to a file + // named for the current test. + $log = $this->getDrupalRoot() . '/sites/simpletest/browser_output'; + @mkdir($log, recursive: TRUE); + $this->assertDirectoryIsWritable($log); + $log .= '/' . str_replace('\\', '_', static::class) . '-' . $this->name(); + if ($this->usesDataProvider()) { + $log .= '-' . preg_replace('/[^a-z0-9]+/i', '_', $this->dataName()); + } + $code .= <<<END +\$config['package_manager.settings']['log'] = '$log-package_manager.log'; +END; + + $this->writeSettings($code); + + // Install helpful modules. + $this->installModules([ + 'package_manager_test_api', + 'package_manager_test_event_logger', + 'package_manager_test_release_history', + ]); + + // Confirm the server time out settings. + // @see \Drupal\Tests\package_manager\Build\TemplateProjectTestBase::instantiateServer() + $this->visit('/package-manager-test-api/check-setup'); + $this->getMink() + ->assertSession() + ->pageTextContains("max_execution_time=" . static::MAX_EXECUTION_TIME . ":set_time_limit-exists=no"); + } + + /** + * Changes constraints for core packages to `*`. + * + * @param string[] $constraints + * A set of version constraints, like you'd find in the `require` or + * `require-dev` sections of `composer.json`. This array is modified by + * reference. + */ + private static function unboundCoreConstraints(array &$constraints): void { + $names = preg_grep('/^drupal\/core-?/', array_keys($constraints)); + foreach ($names as $name) { + $constraints[$name] = '*'; + } + } + + /** + * Creates a Composer repository for all dependencies of the test project. + * + * We always reference third-party dependencies (i.e., any package that isn't + * part of Drupal core) from the main project which is running this test. + * + * Packages that are part of Drupal core -- such as `drupal/core`, + * `drupal/core-composer-scaffold`, and so on -- are expected to have been + * copied into the workspace directory, so that we can modify them as needed. + * + * The file will be written to WORKSPACE_DIR/vendor.json. + * + * @param string[] $versions + * (optional) The versions of specific packages, keyed by package name. + * Versions of packages not in this array will be determined first by + * looking for a `version` key in the package's composer.json, then by + * calling \Composer\InstalledVersions::getPrettyVersion(). If none of that + * works, `dev-main` will be used as the package's version. + */ + protected function createVendorRepository(array $versions = []): void { + $packages = []; + + $class_loaders = ClassLoader::getRegisteredLoaders(); + + $workspace_dir = $this->getWorkspaceDirectory(); + $finder = Finder::create() + ->in([ + $this->getWorkspaceDrupalRoot() . '/core', + "$workspace_dir/composer/Metapackage", + "$workspace_dir/composer/Plugin", + key($class_loaders), + ]) + ->depth('< 3') + ->files() + ->name('composer.json'); + + /** @var \Symfony\Component\Finder\SplFileInfo $file */ + foreach ($finder as $file) { + $package_info = json_decode($file->getContents(), TRUE, flags: JSON_THROW_ON_ERROR); + $name = $package_info['name']; + + $requirements = $package_info['require'] ?? []; + // These polyfills are dependencies of some packages, but for reasons we + // don't understand, they are not installed in code bases built on PHP + // versions that are newer than the ones being polyfilled, which means we + // won't be able to build our test project because these polyfills aren't + // available in the local code base. Since we're guaranteed to be on PHP + // 8.3 or later, no package should need to polyfill older versions. + unset( + $requirements['symfony/polyfill-php72'], + $requirements['symfony/polyfill-php73'], + $requirements['symfony/polyfill-php74'], + $requirements['symfony/polyfill-php80'], + $requirements['symfony/polyfill-php81'], + $requirements['symfony/polyfill-php82'], + $requirements['symfony/polyfill-php83'], + ); + // If this package requires any Drupal core packages, ensure it allows + // any version. + self::unboundCoreConstraints($requirements); + // In certain situations, like Drupal CI, auto_updates might be + // required into the code base by Composer. This may cause it to be added to + // the drupal/core-recommended metapackage, which can prevent the test site + // from being built correctly, among other deleterious effects. To prevent + // such shenanigans, always remove drupal/auto_updates from + // drupal/core-recommended. + if ($name === 'drupal/core-recommended') { + unset($requirements['drupal/auto_updates']); + } + + try { + $version = $versions[$name] ?? $package_info['version'] ?? InstalledVersions::getPrettyVersion($name); + } + catch (\OutOfBoundsException) { + $version = 'dev-main'; + } + + // Create a pared-down package definition that has just enough information + // for Composer to install the package from the local copy: the name, + // version, package type, source path ("dist" in Composer terminology), + // and the autoload information, so that the classes provided by the + // package will actually be loadable in the test site we're building. + $path = $file->getPath(); + $packages[$name][$version] = [ + 'name' => $name, + 'version' => $version, + 'type' => $package_info['type'] ?? 'library', + // Disabling symlinks in the transport options doesn't seem to have an + // effect, so we use the COMPOSER_MIRROR_PATH_REPOS environment + // variable to force mirroring in ::createTestProject(). + 'dist' => [ + 'type' => 'path', + 'url' => $path, + ], + 'require' => $requirements, + 'autoload' => $package_info['autoload'] ?? [], + 'provide' => $package_info['provide'] ?? [], + // Composer plugins are loaded and activated as early as possible, and + // they must have a `class` key defined in their `extra` section, along + // with a dependency on `composer-plugin-api` (plus any other real + // runtime dependencies). This is also necessary for packages that ship + // scaffold files, like Drupal core. + 'extra' => $package_info['extra'] ?? [], + ]; + } + $data = json_encode(['packages' => $packages], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + file_put_contents($workspace_dir . '/vendor.json', $data); + } + + /** + * Runs a Composer command and returns its output. + * + * Always asserts that the command was executed successfully. + * + * @param string $command + * The command to execute, including the `composer` invocation. + * @param string|null $working_dir + * (optional) A working directory relative to the workspace, within which to + * execute the command. Defaults to the workspace directory. + * @param bool $json + * (optional) Whether to parse the command's output as JSON before returning + * it. Defaults to FALSE. + * + * @return mixed|string|null + * The command's output, optionally parsed as JSON. + */ + protected function runComposer(string $command, ?string $working_dir = NULL, bool $json = FALSE) { + $process = $this->executeCommand($command, $working_dir); + $this->assertCommandSuccessful(); + + $output = trim($process->getOutput()); + if ($json) { + $output = json_decode($output, TRUE, flags: JSON_THROW_ON_ERROR); + } + return $output; + } + + /** + * Appends PHP code to the test site's settings.php. + * + * @param string $php + * The PHP code to append to the test site's settings.php. + */ + protected function writeSettings(string $php): void { + // Ensure settings are writable, since this is the only way we can set + // configuration values that aren't accessible in the UI. + $file = $this->getWebRoot() . '/sites/default/settings.php'; + $this->assertFileExists($file); + chmod(dirname($file), 0744); + chmod($file, 0744); + $this->assertFileIsWritable($file); + $this->assertIsInt(file_put_contents($file, $php, FILE_APPEND)); + } + + /** + * Installs modules in the UI. + * + * Assumes that a user with the appropriate permissions is logged in. + * + * @param string[] $modules + * The machine names of the modules to install. + */ + protected function installModules(array $modules): void { + $mink = $this->getMink(); + $page = $mink->getSession()->getPage(); + $assert_session = $mink->assertSession(); + + $this->visit('/admin/modules'); + foreach ($modules as $module) { + $page->checkField("modules[$module][enable]"); + } + $page->pressButton('Install'); + + // If there is a confirmation form warning about additional dependencies + // or non-stable modules, submit it. + $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]') + ->getValue(); + if (preg_match('/^system_modules_(experimental_|non_stable_)?confirm_form$/', $form_id)) { + $page->pressButton('Continue'); + $assert_session->statusCodeEquals(200); + } + } + + /** + * Copies a fixture directory to a temporary directory and returns its path. + * + * @param string $fixture_directory + * The fixture directory. + * + * @return string + * The temporary directory. + */ + protected function copyFixtureToTempDirectory(string $fixture_directory): string { + $temp_directory = $this->getWorkspaceDirectory() . '/fixtures_temp_' . $this->randomMachineName(20); + static::copyFixtureFilesTo($fixture_directory, $temp_directory); + return $temp_directory; + } + + /** + * Asserts stage events were fired in a specific order. + * + * @param string $expected_stage_class + * The expected stage class for the events. + * @param array|null $expected_events + * (optional) The expected stage events that should have been fired in the + * order in which they should have been fired. Events can be specified more + * that once if they will be fired multiple times. If there are no events + * specified all life cycle events from PreCreateEvent to PostApplyEvent + * will be asserted. + * @param int $wait + * (optional) How many seconds to wait for the events to be fired. Defaults + * to 0. + * @param string $message + * (optional) A message to display with the assertion. + * + * @see \Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber::logEventInfo + */ + protected function assertExpectedStageEventsFired(string $expected_stage_class, ?array $expected_events = NULL, int $wait = 0, string $message = ''): void { + if ($expected_events === NULL) { + $expected_events = EventLogSubscriber::getSubscribedEvents(); + // The event subscriber uses this event to ensure the log file is excluded + // from Package Manager operations, but it's not relevant for our purposes + // because it's not part of the stage life cycle. + unset($expected_events[CollectPathsToExcludeEvent::class]); + $expected_events = array_keys($expected_events); + } + $this->assertNotEmpty($expected_events); + + $log_file = $this->getWorkspaceDirectory() . '/project/' . EventLogSubscriber::LOG_FILE_NAME; + $max_wait = time() + $wait; + do { + $this->assertFileIsReadable($log_file); + $log_data = file_get_contents($log_file); + $log_data = json_decode($log_data, TRUE, flags: JSON_THROW_ON_ERROR); + + // Filter out events logged by any other stage. + $log_data = array_filter($log_data, fn (array $event): bool => $event['stage'] === $expected_stage_class); + + // If we've logged at least the expected number of events, stop waiting. + // Break out of the loop and assert the expected events were logged. + if (count($log_data) >= count($expected_events)) { + break; + } + // Wait a bit before checking again. + sleep(5); + } while ($max_wait > time()); + + $this->assertSame($expected_events, array_column($log_data, 'event'), $message); + } + + /** + * Visits the 'admin/reports/dblog' and selects Package Manager's change log. + */ + private function visitPackageManagerChangeLog(): void { + $mink = $this->getMink(); + $assert_session = $mink->assertSession(); + $page = $mink->getSession()->getPage(); + + $this->visit('/admin/reports/dblog'); + $assert_session->statusCodeEquals(200); + $page->selectFieldOption('Type', 'package_manager_change_log'); + $page->pressButton('Filter'); + $assert_session->statusCodeEquals(200); + } + + /** + * Asserts changes requested during the stage life cycle were logged. + * + * This method specifically asserts changes that were *requested* (i.e., + * during the require phase) rather than changes that were actually applied. + * The requested and applied changes may be exactly the same, or they may + * differ (for example, if a secondary dependency was added or updated in the + * stage directory). + * + * @param string[] $expected_requested_changes + * The expected requested changes. + * + * @see ::assertAppliedChangesWereLogged() + * @see \Drupal\package_manager\EventSubscriber\ChangeLogger + */ + protected function assertRequestedChangesWereLogged(array $expected_requested_changes): void { + $this->visitPackageManagerChangeLog(); + $assert_session = $this->getMink()->assertSession(); + + $assert_session->elementExists('css', 'a[href*="/admin/reports/dblog/event/"]:contains("Requested changes:")') + ->click(); + array_walk($expected_requested_changes, $assert_session->pageTextContains(...)); + } + + /** + * Asserts that changes applied during the stage life cycle were logged. + * + * This method specifically asserts changes that were *applied*, rather than + * the changes that were merely requested. For example, if a package was + * required into the stage and it added a secondary dependency, that change + * will be considered one of the applied changes, not a requested change. + * + * @param string[] $expected_applied_changes + * The expected applied changes. + * + * @see ::assertRequestedChangesWereLogged() + * @see \Drupal\package_manager\EventSubscriber\ChangeLogger + */ + protected function assertAppliedChangesWereLogged(array $expected_applied_changes): void { + $this->visitPackageManagerChangeLog(); + $assert_session = $this->getMink()->assertSession(); + + $assert_session->elementExists('css', 'a[href*="/admin/reports/dblog/event/"]:contains("Applied changes:")') + ->click(); + array_walk($expected_applied_changes, $assert_session->pageTextContains(...)); + } + + /** + * Gets a /package-manager-test-api response. + * + * @param string $url + * The package manager test API URL to fetch. + * @param array $query_data + * The query data. + */ + protected function makePackageManagerTestApiRequest(string $url, array $query_data): void { + $url .= '?' . http_build_query($query_data); + $this->visit($url); + $session = $this->getMink()->getSession(); + + // Ensure test failures provide helpful debug output when there's a fatal + // PHP error: don't use \Behat\Mink\WebAssert::statusCodeEquals(). + $message = sprintf( + "Error response: %s\n\nHeaders: %s\n\nServer error log: %s", + $session->getPage()->getContent(), + var_export($session->getResponseHeaders(), TRUE), + $this->serverErrorLog, + ); + $this->assertSame(200, $session->getStatusCode(), $message); + } + + /** + * {@inheritdoc} + */ + public function copyCodebase(?\Iterator $iterator = NULL, $working_dir = NULL): void { + parent::copyCodebase($iterator, $working_dir); + + // Create a local Composer repository for all third-party dependencies and + // core packages. + $this->createVendorRepository(); + } + +} diff --git a/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php b/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php new file mode 100644 index 00000000000..f82e051eb82 --- /dev/null +++ b/core/modules/package_manager/tests/src/Functional/ComposerRequirementTest.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Functional; + +use Drupal\package_manager\ComposerInspector; +use Drupal\Tests\BrowserTestBase; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; + +/** + * Tests that Package Manager shows the Composer version on the status report. + * + * @group package_manager + * @internal + */ +class ComposerRequirementTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests that Composer version and path are listed on the status report. + */ + public function testComposerInfoShown(): void { + $config = $this->config('package_manager.settings'); + + // Ensure we can locate the Composer executable. + /** @var \PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface $executable_finder */ + $executable_finder = $this->container->get(ExecutableFinderInterface::class); + $composer_path = $executable_finder->find('composer'); + $composer_version = $this->container->get(ComposerInspector::class)->getVersion(); + + // With a valid path to Composer, ensure the status report shows its version + // number and path. + $config->set('executables.composer', $composer_path)->save(); + $account = $this->drupalCreateUser(['administer site configuration']); + $this->drupalLogin($account); + $this->drupalGet('/admin/reports/status'); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Composer version'); + $assert_session->responseContains("$composer_version (<code>$composer_path</code>)"); + + // If the path to Composer is invalid, we should see the error message + // that gets raised when we try to get its version. + $config->set('executables.composer', '/path/to/composer')->save(); + $this->getSession()->reload(); + $assert_session->statusCodeEquals(200); + $assert_session->pageTextContains('Composer was not found. The error message was: Failed to run process: The command "\'/path/to/composer\' \'--format=json\'" failed.'); + } + +} diff --git a/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php b/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php new file mode 100644 index 00000000000..5a5509d31c8 --- /dev/null +++ b/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php @@ -0,0 +1,73 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Functional; + +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StageBase; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; + +/** + * Tests that Package Manager's requirements check for the failure marker. + * + * @group package_manager + * @internal + */ +class FailureMarkerRequirementTest extends BrowserTestBase { + use StringTranslationTrait; + + use AssertPreconditionsTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'package_manager', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * Tests that error is shown if failure marker already exists. + */ + public function testFailureMarkerExists(): void { + $account = $this->drupalCreateUser([ + 'administer site configuration', + ]); + $this->drupalLogin($account); + + $fake_project_root = $this->root . DIRECTORY_SEPARATOR . $this->publicFilesDirectory; + $this->container->get(PathLocator::class) + ->setPaths($fake_project_root, NULL, NULL, NULL); + + $failure_marker = $this->container->get(FailureMarker::class); + $message = $this->t('Package Manager is here to wreck your day.'); + $stage = new class() extends StageBase { + + public function __construct() {} + + /** + * {@inheritdoc} + */ + protected string $type = 'test'; + }; + $failure_marker->write($stage, $message); + $path = $failure_marker->getPath(); + $this->assertFileExists($path); + $this->assertStringStartsWith($fake_project_root, $path); + + $this->drupalGet('/admin/reports/status'); + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Failed Package Manager update detected'); + $assert_session->pageTextContains($message->render()); + } + +} diff --git a/core/modules/package_manager/tests/src/Functional/GenericTest.php b/core/modules/package_manager/tests/src/Functional/GenericTest.php new file mode 100644 index 00000000000..d096f0c7378 --- /dev/null +++ b/core/modules/package_manager/tests/src/Functional/GenericTest.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Functional; + +use Drupal\Tests\system\Functional\Module\GenericModuleTestBase; + +/** + * Generic module test for package_manager. + * + * @group package_manager + */ +class GenericTest extends GenericModuleTestBase {} diff --git a/core/modules/package_manager/tests/src/Kernel/AllowedScaffoldPackagesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/AllowedScaffoldPackagesValidatorTest.php new file mode 100644 index 00000000000..96c80e75d72 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/AllowedScaffoldPackagesValidatorTest.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\AllowedScaffoldPackagesValidator + * @group package_manager + * @internal + */ +class AllowedScaffoldPackagesValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests that the allowed-packages setting is validated during pre-create. + */ + public function testPreCreate(): void { + (new ActiveFixtureManipulator())->addConfig([ + 'extra.drupal-scaffold.allowed-packages' => [ + "drupal/dummy_scaffolding", + "drupal/dummy_scaffolding_2", + ], + ])->commitChanges(); + + $result = ValidationResult::createError( + [ + t("drupal/dummy_scaffolding"), + t("drupal/dummy_scaffolding_2"), + ], + t('Any packages other than the implicitly allowed packages are not allowed to scaffold files. See <a href="https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold">the scaffold documentation</a> for more information.') + ); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that the allowed-packages setting is validated during pre-apply. + */ + public function testPreApply(): void { + $this->getStageFixtureManipulator() + ->addConfig([ + 'extra.drupal-scaffold.allowed-packages' => [ + "drupal/dummy_scaffolding", + ], + ]); + + $result = ValidationResult::createError( + [ + t("drupal/dummy_scaffolding"), + ], + t('Any packages other than the implicitly allowed packages are not allowed to scaffold files. See <a href="https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold">the scaffold documentation</a> for more information.') + ); + $this->assertResults([$result], PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/BaseRequirementsFulfilledValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/BaseRequirementsFulfilledValidatorTest.php new file mode 100644 index 00000000000..56c8984388b --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/BaseRequirementsFulfilledValidatorTest.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator; +use Drupal\package_manager\Validator\BaseRequirementValidatorTrait; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * @covers \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator + * @covers \Drupal\package_manager\Validator\BaseRequirementValidatorTrait + * + * @group package_manager + */ +class BaseRequirementsFulfilledValidatorTest extends PackageManagerKernelTestBase implements EventSubscriberInterface { + + use BaseRequirementValidatorTrait; + + /** + * The event class to throw to an error for. + * + * @var string + */ + private string $eventClass; + + /** + * {@inheritdoc} + */ + public function validate(PreOperationStageEvent $event): void { + if (get_class($event) === $this->eventClass) { + $event->addError([ + t('This will not stand!'), + ]); + } + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->container->get('event_dispatcher')->addSubscriber($this); + } + + /** + * Data provider for ::testBaseRequirement(). + * + * @return array[] + * The test cases. + */ + public static function providerBaseRequirement(): array { + return [ + [PreCreateEvent::class], + [PreRequireEvent::class], + [PreApplyEvent::class], + [StatusCheckEvent::class], + ]; + } + + /** + * Tests that base requirement failures stop event propagation. + * + * @param string $event_class + * The event which should raise a base requirement error, and thus stop + * event propagation. + * + * @dataProvider providerBaseRequirement + */ + public function testBaseRequirement(string $event_class): void { + $this->eventClass = $event_class; + + $validator = $this->container->get(BaseRequirementsFulfilledValidator::class); + $this->assertEventPropagationStopped($event_class, [$validator, 'validate']); + + $result = ValidationResult::createError([ + t('This will not stand!'), + ]); + + if ($event_class === StatusCheckEvent::class) { + $this->assertStatusCheckResults([$result]); + } + else { + $this->assertResults([$result], $event_class); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ChangeLoggerTest.php b/core/modules/package_manager/tests/src/Kernel/ChangeLoggerTest.php new file mode 100644 index 00000000000..8a7d5e41169 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ChangeLoggerTest.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Psr\Log\LogLevel; + +/** + * @covers \Drupal\package_manager\EventSubscriber\ChangeLogger + * @group package_manager + */ +class ChangeLoggerTest extends PackageManagerKernelTestBase { + + /** + * The logger to which change lists will be written. + * + * @var \ColinODell\PsrTestLogger\TestLogger + */ + private TestLogger $logger; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + $this->logger = new TestLogger(); + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + $container->set('logger.channel.package_manager_change_log', $this->logger); + } + + /** + * Tests that the requested and applied changes are logged. + */ + public function testChangeLogging(): void { + $this->setReleaseMetadata([ + 'semver_test' => __DIR__ . '/../../fixtures/release-history/semver_test.1.1.xml', + ]); + + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'package/removed', + 'type' => 'library', + ]) + ->commitChanges(); + + $this->getStageFixtureManipulator() + ->setCorePackageVersion('9.8.1') + ->addPackage([ + 'name' => 'drupal/semver_test', + 'type' => 'drupal-module', + 'version' => '8.1.1', + ]) + ->removePackage('package/removed'); + + $stage = $this->createStage(); + $stage->create(); + $stage->require([ + 'drupal/semver_test:*', + 'drupal/core-recommended:^9.8.1', + ]); + // Nothing should be logged until post-apply. + $stage->apply(); + $this->assertEmpty($this->logger->records); + $stage->postApply(); + + $this->assertTrue($this->logger->hasInfoRecords()); + $records = $this->logger->recordsByLevel[LogLevel::INFO]; + $this->assertCount(2, $records); + // The first record should be of the requested changes. + $expected_message = <<<END +Requested changes: +- Update drupal/core-recommended from 9.8.0 to ^9.8.1 +- Install drupal/semver_test * (any version) +END; + $this->assertSame($expected_message, (string) $records[0]['message']); + + // The second record should be of the actual changes. + $expected_message = <<<END +Applied changes: +- Updated drupal/core from 9.8.0 to 9.8.1 +- Updated drupal/core-dev from 9.8.0 to 9.8.1 +- Updated drupal/core-recommended from 9.8.0 to 9.8.1 +- Installed drupal/semver_test 8.1.1 +- Uninstalled package/removed +END; + $this->assertSame($expected_message, (string) $records[1]['message']); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php new file mode 100644 index 00000000000..aa3e4943278 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php @@ -0,0 +1,560 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Composer\Json\JsonFile; +use Drupal\Component\Serialization\Json; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Exception\ComposerNotReadyException; +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\Tests\package_manager\Traits\InstalledPackagesListTrait; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Exception\PreconditionException; +use PhpTuf\ComposerStager\API\Exception\RuntimeException; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use PhpTuf\ComposerStager\API\Precondition\Service\ComposerIsAvailableInterface; +use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; +use Prophecy\Argument; +use Prophecy\Prophecy\ObjectProphecy; + +/** + * @coversDefaultClass \Drupal\package_manager\ComposerInspector + * + * @group package_manager + */ +class ComposerInspectorTest extends PackageManagerKernelTestBase { + + use InstalledPackagesListTrait; + + /** + * @covers ::getConfig + */ + public function testConfig(): void { + $dir = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $inspector = $this->container->get(ComposerInspector::class); + $this->assertTrue((bool) Json::decode($inspector->getConfig('secure-http', $dir))); + + $this->assertSame([ + 'boo' => 'boo boo', + "foo" => ["dev" => "2.x-dev"], + "foo-bar" => TRUE, + "boo-far" => [ + "foo" => 1.23, + "bar" => 134, + "foo-bar" => NULL, + ], + 'baz' => NULL, + 'installer-paths' => [ + 'modules/contrib/{$name}' => ['type:drupal-module'], + 'profiles/contrib/{$name}' => ['type:drupal-profile'], + 'themes/contrib/{$name}' => ['type:drupal-theme'], + ], + ], Json::decode($inspector->getConfig('extra', $dir))); + + try { + $inspector->getConfig('non-existent-config', $dir); + $this->fail('Expected an exception when trying to get a non-existent config key, but none was thrown.'); + } + catch (RuntimeException) { + // We don't need to do anything here. + } + + // If composer.json is removed, we should get an exception because + // getConfig() should validate that $dir is Composer-ready. + unlink($dir . '/composer.json'); + $this->expectExceptionMessage("composer.json not found."); + $inspector->getConfig('extra', $dir); + } + + /** + * @covers ::getConfig + */ + public function testConfigUndefinedKey(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $inspector = $this->container->get(ComposerInspector::class); + + // Overwrite the composer.json file and treat it as a + $file = new JsonFile($project_root . '/composer.json'); + $this->assertTrue($file->exists()); + $data = $file->read(); + // Ensure that none of the special keys are defined, to test the fallback + // behavior. + unset( + $data['minimum-stability'], + $data['extra'] + ); + $file->write($data); + + $path = $file->getPath(); + $this->assertSame('stable', $inspector->getConfig('minimum-stability', $path)); + $this->assertSame([], Json::decode($inspector->getConfig('extra', $path))); + } + + /** + * @covers ::getInstalledPackagesList + */ + public function testGetInstalledPackagesList(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + $list = $inspector->getInstalledPackagesList($project_root); + + $expected_list = new InstalledPackagesList([ + 'drupal/core' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core', + 'type' => 'drupal-core', + 'version' => '9.8.0', + 'path' => "$project_root/vendor/drupal/core", + ]), + 'drupal/core-recommended' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-recommended', + 'type' => 'project', + 'version' => '9.8.0', + 'path' => "$project_root/vendor/drupal/core-recommended", + ]), + 'drupal/core-dev' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-dev', + 'type' => 'package', + 'version' => '9.8.0', + 'path' => "$project_root/vendor/drupal/core-dev", + ]), + ]); + + $this->assertPackageListsEqual($expected_list, $list); + + // Since the lock file hasn't changed, we should get the same package list + // back if we call getInstalledPackageList() again. + $this->assertSame($list, $inspector->getInstalledPackagesList($project_root)); + + // If we change the lock file, we should get a different package list. + $lock_file = new JsonFile($project_root . '/composer.lock'); + $lock_data = $lock_file->read(); + $this->assertArrayHasKey('_readme', $lock_data); + unset($lock_data['_readme']); + $lock_file->write($lock_data); + $this->assertNotSame($list, $inspector->getInstalledPackagesList($project_root)); + + // If composer.lock is removed, we should get an exception because + // getInstalledPackagesList() should validate that $project_root is + // Composer-ready. + unlink($lock_file->getPath()); + $this->expectExceptionMessage("composer.lock not found in $project_root."); + $inspector->getInstalledPackagesList($project_root); + } + + /** + * @covers ::validate + */ + public function testComposerUnavailable(): void { + $precondition = $this->prophesize(ComposerIsAvailableInterface::class); + $mocked_precondition = $precondition->reveal(); + $this->container->set(ComposerIsAvailableInterface::class, $mocked_precondition); + + $message = $this->createComposeStagerMessage("Well, that didn't work."); + $precondition->assertIsFulfilled(Argument::cetera()) + ->willThrow(new PreconditionException($mocked_precondition, $message)) + // The result of the precondition is statically cached, so it should only + // be called once even though we call validate() twice. + ->shouldBeCalledOnce(); + + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + try { + $inspector->validate($project_root); + $this->fail('Expected an exception to be thrown, but it was not.'); + } + catch (ComposerNotReadyException $e) { + $this->assertNull($e->workingDir); + $this->assertSame("Well, that didn't work.", $e->getMessage()); + } + + // Call validate() again to ensure the precondition is called once. + $this->expectException(ComposerNotReadyException::class); + $this->expectExceptionMessage("Well, that didn't work."); + $inspector->validate($project_root); + } + + /** + * Tests what happens when composer.json or composer.lock are missing. + * + * @param string $filename + * The filename to delete, which should cause validate() to raise an + * error. + * + * @covers ::validate + * + * @testWith ["composer.json"] + * ["composer.lock"] + */ + public function testComposerFilesDoNotExist(string $filename): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + $file_path = $project_root . '/' . $filename; + unlink($file_path); + + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + try { + $inspector->validate($project_root); + } + catch (ComposerNotReadyException $e) { + $this->assertSame($project_root, $e->workingDir); + $this->assertStringContainsString("$filename not found", $e->getMessage()); + } + } + + /** + * @param string|null $reported_version + * The version of Composer that will be returned by ::getVersion(). + * @param string|null $expected_message + * The error message that should be generated for the reported version of + * Composer. If not passed, will default to the message format defined in + * ::validate(). + * + * @covers ::validate + * + * @testWith ["2.2.12", "<default>"] + * ["2.2.13", "<default>"] + * ["2.5.0", "<default>"] + * ["2.5.5", "<default>"] + * ["2.5.11", "<default>"] + * ["2.6.0", null] + * ["2.2.11", "<default>"] + * ["2.2.0-dev", "<default>"] + * ["2.3.6", "<default>"] + * ["2.4.1", "<default>"] + * ["2.3.4", "<default>"] + * ["2.1.6", "<default>"] + * ["1.10.22", "<default>"] + * ["1.7.3", "<default>"] + * ["2.0.0-alpha3", "<default>"] + * ["2.1.0-RC1", "<default>"] + * ["1.0.0-RC", "<default>"] + * ["1.0.0-beta1", "<default>"] + * ["1.9-dev", "<default>"] + * ["@package_version@", "Invalid version string \"@package_version@\""] + * [null, "Unable to determine Composer version"] + */ + public function testVersionCheck(?string $reported_version, ?string $expected_message): void { + $runner = $this->mockComposerRunner($reported_version); + + // Mock the ComposerIsAvailableInterface so that if it uses the Composer + // runner it will not affect the test expectations. + $composerPrecondition = $this->prophesize(ComposerIsAvailableInterface::class); + $composerPrecondition + ->assertIsFulfilled(Argument::cetera()) + ->shouldBeCalledOnce(); + $this->container->set(ComposerIsAvailableInterface::class, $composerPrecondition->reveal()); + + // The result of the version check is statically cached, so the runner + // should only be called once, even though we call validate() twice in this + // test. + $runner->getMethodProphecies('run')[0]->withArguments([['--format=json'], NULL, [], Argument::any()])->shouldBeCalledOnce(); + // The runner should be called with `validate` as the first argument, but + // it won't affect the outcome of this test. + $runner->run(Argument::withEntry(0, 'validate')); + $this->container->set(ComposerProcessRunnerInterface::class, $runner->reveal()); + + if ($expected_message === '<default>') { + $expected_message = "The detected Composer version, $reported_version, does not satisfy <code>" . ComposerInspector::SUPPORTED_VERSION . '</code>.'; + } + + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + try { + $inspector->validate($project_root); + // If we expected the version check to succeed, ensure we did not expect + // an exception message. + $this->assertNull($expected_message, 'Expected an exception, but none was thrown.'); + } + catch (ComposerNotReadyException $e) { + $this->assertNull($e->workingDir); + $this->assertSame($expected_message, $e->getMessage()); + } + + if (isset($expected_message)) { + $this->expectException(ComposerNotReadyException::class); + $this->expectExceptionMessage($expected_message); + } + $inspector->validate($project_root); + } + + /** + * @covers ::getVersion + * + * @testWith ["2.5.6"] + * [null] + */ + public function testGetVersion(?string $reported_version): void { + $this->container->set(ComposerProcessRunnerInterface::class, $this->mockComposerRunner($reported_version)->reveal()); + + if (empty($reported_version)) { + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Unable to determine Composer version'); + } + $this->assertSame($reported_version, $this->container->get(ComposerInspector::class)->getVersion()); + } + + /** + * @covers ::validate + */ + public function testComposerValidateIsCalled(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + // Put an invalid value into composer.json and ensure it gets surfaced as + // an exception. + $file = new JsonFile($project_root . '/composer.json'); + $this->assertTrue($file->exists()); + $data = $file->read(); + $data['prefer-stable'] = 'truthy'; + $file->write($data); + + try { + $this->container->get(ComposerInspector::class) + ->validate($project_root); + $this->fail('Expected an exception to be thrown, but it was not.'); + } + catch (ComposerNotReadyException $e) { + $this->assertSame($project_root, $e->workingDir); + // The exception message is translated by Composer Stager and HTML-escaped + // by Drupal's markup system, which is why there's a " in the + // final exception message. + $this->assertStringContainsString('composer.json" does not match the expected JSON schema', $e->getMessage()); + $this->assertStringContainsString('prefer-stable : String value found, but a boolean is required', $e->getPrevious()?->getMessage()); + } + } + + /** + * @covers ::getRootPackageInfo + */ + public function testRootPackageInfo(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + $info = $this->container->get(ComposerInspector::class) + ->getRootPackageInfo($project_root); + $this->assertSame('fake/site', $info['name']); + } + + /** + * Tests that the installed path of metapackages is always NULL. + * + * @param bool $is_metapackage + * Whether the test package will be a metapackage. + * @param string|null $install_path + * The package install path that Composer should report. If NULL, the + * reported path will be unchanged. The token <PROJECT_ROOT> will be + * replaced with the project root. + * @param string|null $exception_message + * The expected exception message, or NULL if no exception should be thrown. + * The token <PROJECT_ROOT> will be replaced with the project root. + * + * @covers ::getInstalledPackagesList + * + * @testWith [true, null, null] + * [true, "<PROJECT_ROOT>/another/directory", "Metapackage 'test/package' is installed at unexpected path: '<PROJECT_ROOT>/another/directory', expected NULL"] + * [false, null, null] + * [false, "<PROJECT_ROOT>", "Package 'test/package' cannot be installed at path: '<PROJECT_ROOT>'"] + */ + public function testMetapackagePath(bool $is_metapackage, ?string $install_path, ?string $exception_message): void { + $inspector = new class ( + $this->container->get(ComposerProcessRunnerInterface::class), + $this->container->get(ComposerIsAvailableInterface::class), + $this->container->get(PathFactoryInterface::class), + ) extends ComposerInspector { + + /** + * The install path that Composer should report for `test/package`. + * + * If not set, the reported install path will not be changed. + * + * @var string + */ + public $packagePath; + + /** + * {@inheritdoc} + */ + protected function show(string $working_dir): array { + $data = parent::show($working_dir); + if ($this->packagePath) { + $data['test/package']['path'] = $this->packagePath; + } + return $data; + } + + }; + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + if ($install_path) { + $install_path = str_replace('<PROJECT_ROOT>', $project_root, $install_path); + + // The install path must actually exist. + if (!is_dir($install_path)) { + $this->assertTrue(mkdir($install_path, 0777, TRUE)); + } + $inspector->packagePath = $install_path; + } + + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => 'test/package', + 'type' => $is_metapackage ? 'metapackage' : 'library', + ]) + ->commitChanges(); + + if ($exception_message) { + $this->expectException(\UnexpectedValueException::class); + $exception_message = str_replace('<PROJECT_ROOT>', $project_root, $exception_message); + $this->expectExceptionMessage($exception_message); + } + $list = $inspector->getInstalledPackagesList($project_root); + $this->assertArrayHasKey('test/package', $list); + // If the package is a metapackage, its path should be NULL. + $this->assertSame($is_metapackage, is_null($list['test/package']->path)); + } + + /** + * Tests that the commit hash of a dev snapshot package is ignored. + */ + public function testPackageDevSnapshotCommitHashIsRemoved(): void { + $inspector = new class ( + $this->container->get(ComposerProcessRunnerInterface::class), + $this->container->get(ComposerIsAvailableInterface::class), + $this->container->get(PathFactoryInterface::class), + ) extends ComposerInspector { + + /** + * {@inheritdoc} + */ + protected function show(string $working_dir): array { + return [ + 'test/package' => [ + 'name' => 'test/package', + 'path' => __DIR__, + 'version' => '1.0.x-dev 0a1b2c3d', + ], + ]; + } + + }; + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $list = $inspector->getInstalledPackagesList($project_root); + $this->assertSame('1.0.x-dev', $list['test/package']->version); + } + + /** + * Data provider for ::testAllowedPlugins(). + * + * @return array[] + * The test cases. + */ + public static function providerAllowedPlugins(): array { + return [ + 'all plugins allowed' => [ + ['allow-plugins' => TRUE], + TRUE, + ], + 'no plugins allowed' => [ + ['allow-plugins' => FALSE], + [], + ], + 'some plugins allowed' => [ + [ + 'allow-plugins.example/plugin-a' => TRUE, + 'allow-plugins.example/plugin-b' => FALSE, + ], + [ + 'example/plugin-a' => TRUE, + 'example/plugin-b' => FALSE, + // The scaffold plugin is explicitly disallowed by the fake_site + // fixture. + 'drupal/core-composer-scaffold' => FALSE, + ], + ], + ]; + } + + /** + * Tests ComposerInspector's parsing of the allowed plugins list. + * + * @param array $config + * The Composer configuration to set. + * @param array|bool $expected_value + * The expected return value from getAllowPluginsConfig(). + * + * @covers ::getAllowPluginsConfig + * + * @dataProvider providerAllowedPlugins + */ + public function testAllowedPlugins(array $config, bool|array $expected_value): void { + (new ActiveFixtureManipulator()) + ->addConfig($config) + ->commitChanges(); + + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + $actual_value = $this->container->get(ComposerInspector::class) + ->getAllowPluginsConfig($project_root); + + if (is_array($expected_value)) { + ksort($expected_value); + } + if (is_array($actual_value)) { + ksort($actual_value); + } + $this->assertSame($expected_value, $actual_value); + } + + /** + * Mocks the Composer runner service to return a particular version string. + * + * @param string|null $reported_version + * The version number that `composer --format=json` should return. + * + * @return \Prophecy\Prophecy\ObjectProphecy + * The configurator for the mocked Composer runner. + */ + private function mockComposerRunner(?string $reported_version): ObjectProphecy { + $runner = $this->prophesize(ComposerProcessRunnerInterface::class); + + $pass_version_to_output_callback = function (array $arguments_passed_to_runner) use ($reported_version): void { + $command_output = Json::encode([ + 'application' => [ + 'name' => 'Composer', + 'version' => $reported_version, + ], + ]); + + $callback = end($arguments_passed_to_runner); + assert($callback instanceof OutputCallbackInterface); + $callback(OutputTypeEnum::OUT, $command_output); + }; + + // We expect the runner to be called with two arguments: an array whose + // first item is `--format=json`, and an output callback. + $runner->run( + Argument::withEntry(0, '--format=json'), + Argument::cetera(), + )->will($pass_version_to_output_callback); + + return $runner; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerMinimumStabilityValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerMinimumStabilityValidatorTest.php new file mode 100644 index 00000000000..ce6c4a60a4b --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerMinimumStabilityValidatorTest.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerMinimumStabilityValidator + * @group package_manager + * @internal + */ +class ComposerMinimumStabilityValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests error if requested version is less stable than the minimum: stable. + */ + public function testPreRequireEvent(): void { + $stage = $this->createStage(); + $stage->create(); + $result = ValidationResult::createError([ + t("<code>drupal/core</code>'s requested version 9.8.1-beta1 is less stable (beta) than the minimum stability (stable) required in <PROJECT_ROOT>/composer.json."), + ]); + try { + $stage->require(['drupal/core:9.8.1-beta1']); + $this->fail('Able to require a package even though it did not meet minimum stability.'); + } + catch (StageEventException $exception) { + $this->assertValidationResultsEqual([$result], $exception->event->getResults()); + } + $stage->destroy(); + + // Specifying a stability flag bypasses this check. + $stage->create(); + $stage->require(['drupal/core:9.8.1-beta1@dev']); + $stage->destroy(); + + // Dev packages are also checked. + $stage->create(); + $result = ValidationResult::createError([ + t("<code>drupal/core-dev</code>'s requested version 9.8.x-dev is less stable (dev) than the minimum stability (stable) required in <PROJECT_ROOT>/composer.json."), + ]); + try { + $stage->require([], ['drupal/core-dev:9.8.x-dev']); + $this->fail('Able to require a package even though it did not meet minimum stability.'); + } + catch (StageEventException $exception) { + $this->assertValidationResultsEqual([$result], $exception->event->getResults()); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php new file mode 100644 index 00000000000..d3652407d8d --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php @@ -0,0 +1,292 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\Url; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerPatchesValidator + * @group package_manager + * @internal + */ +class ComposerPatchesValidatorTest extends PackageManagerKernelTestBase { + + const ABSENT = 0; + const CONFIG_ALLOWED_PLUGIN = 1; + const EXTRA_EXIT_ON_PATCH_FAILURE = 2; + const REQUIRE_PACKAGE_FROM_ROOT = 4; + const REQUIRE_PACKAGE_INDIRECTLY = 8; + + /** + * Data provider for testErrorDuringPreCreate(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerErrorDuringPreCreate(): array { + $summary = t('Problems detected related to the Composer plugin <code>cweagans/composer-patches</code>.'); + return [ + 'INVALID: exit-on-patch-failure missing' => [ + static::CONFIG_ALLOWED_PLUGIN | static::REQUIRE_PACKAGE_FROM_ROOT, + [ + ValidationResult::createError([ + t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.'), + ], $summary), + ], + ], + 'INVALID: indirect dependency' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_INDIRECTLY, + [ + ValidationResult::createError([ + t('It must be a root dependency.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-not-a-root-dependency', + NULL, + ], + ], + 'VALID: present' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + [], + ], + 'VALID: absent' => [ + static::ABSENT, + [], + ], + ]; + } + + /** + * Tests that the patcher configuration is validated during pre-create. + * + * @param int $options + * What aspects of the patcher are installed how. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerErrorDuringPreCreate + */ + public function testErrorDuringPreCreate(int $options, array $expected_results): void { + $active_manipulator = new ActiveFixtureManipulator(); + if ($options & static::CONFIG_ALLOWED_PLUGIN) { + $active_manipulator->addConfig(['allow-plugins.cweagans/composer-patches' => TRUE]); + } + if ($options & static::EXTRA_EXIT_ON_PATCH_FAILURE) { + $active_manipulator->addConfig(['extra.composer-exit-on-patch-failure' => TRUE]); + } + if ($options & static::REQUIRE_PACKAGE_FROM_ROOT) { + $active_manipulator->requirePackage('cweagans/composer-patches', '@dev'); + } + elseif ($options & static::REQUIRE_PACKAGE_INDIRECTLY) { + $active_manipulator->addPackage([ + 'type' => 'package', + 'name' => 'dummy/depends-on-composer-patches', + 'description' => 'A dummy package depending on cweagans/composer-patches', + 'version' => '1.0.0', + 'require' => ['cweagans/composer-patches' => '*'], + ]); + } + if ($options !== static::ABSENT) { + $active_manipulator->commitChanges(); + } + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Data provider for testErrorDuringPreApply() and testHelpLink(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerErrorDuringPreApply(): array { + $summary = t('Problems detected related to the Composer plugin <code>cweagans/composer-patches</code>.'); + + return [ + 'composer-patches present in stage, but not present in active' => [ + static::ABSENT, + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + [ + ValidationResult::createError([ + t('It cannot be installed by Package Manager.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-installed-or-removed', + ], + ], + 'composer-patches partially present (exit missing) in stage, but not present in active' => [ + static::ABSENT, + static::CONFIG_ALLOWED_PLUGIN | static::REQUIRE_PACKAGE_FROM_ROOT, + [ + ValidationResult::createError([ + t('It cannot be installed by Package Manager.'), + t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-installed-or-removed', + NULL, + ], + ], + 'composer-patches present due to non-root dependency in stage, but not present in active' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE, + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_INDIRECTLY, + [ + ValidationResult::createError([ + t('It cannot be installed by Package Manager.'), + t('It must be a root dependency.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-installed-or-removed', + 'package-manager-faq-composer-patches-not-a-root-dependency', + NULL, + ], + ], + 'composer-patches removed in stage, but present in active' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + static::ABSENT, + [ + ValidationResult::createError([ + t('It cannot be removed by Package Manager.'), + ], $summary), + ], + [ + 'package-manager-faq-composer-patches-installed-or-removed', + ], + ], + 'composer-patches present in stage and active' => [ + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + static::CONFIG_ALLOWED_PLUGIN | static::EXTRA_EXIT_ON_PATCH_FAILURE | static::REQUIRE_PACKAGE_FROM_ROOT, + [], + [], + ], + 'composer-patches not present in stage and active' => [ + static::ABSENT, + static::ABSENT, + [], + [], + ], + ]; + } + + /** + * Tests the patcher's presence and configuration are validated on pre-apply. + * + * @param int $in_active + * Whether patcher is installed in active. + * @param int $in_stage + * Whether patcher is installed in stage. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerErrorDuringPreApply + */ + public function testErrorDuringPreApply(int $in_active, int $in_stage, array $expected_results): void { + // Simulate in active. + $active_manipulator = new ActiveFixtureManipulator(); + if ($in_active & static::CONFIG_ALLOWED_PLUGIN) { + $active_manipulator->addConfig(['allow-plugins.cweagans/composer-patches' => TRUE]); + } + if ($in_active & static::EXTRA_EXIT_ON_PATCH_FAILURE) { + $active_manipulator->addConfig(['extra.composer-exit-on-patch-failure' => TRUE]); + } + if ($in_active & static::REQUIRE_PACKAGE_FROM_ROOT) { + $active_manipulator->requirePackage('cweagans/composer-patches', '@dev'); + } + if ($in_active !== static::ABSENT) { + $active_manipulator->commitChanges(); + } + + // Simulate in stage. + $stage_manipulator = $this->getStageFixtureManipulator(); + if ($in_stage & static::CONFIG_ALLOWED_PLUGIN) { + $stage_manipulator->addConfig([ + 'allow-plugins.cweagans/composer-patches' => TRUE, + ]); + } + if ($in_stage & static::EXTRA_EXIT_ON_PATCH_FAILURE) { + $stage_manipulator->addConfig([ + 'extra.composer-exit-on-patch-failure' => TRUE, + ]); + } + if ($in_stage & static::REQUIRE_PACKAGE_FROM_ROOT && !($in_active & static::REQUIRE_PACKAGE_FROM_ROOT)) { + $stage_manipulator->requirePackage('cweagans/composer-patches', '1.7.333'); + } + if (!($in_stage & static::REQUIRE_PACKAGE_FROM_ROOT) && $in_active & static::REQUIRE_PACKAGE_FROM_ROOT) { + $stage_manipulator + ->removePackage('cweagans/composer-patches'); + } + if ($in_stage & static::REQUIRE_PACKAGE_INDIRECTLY) { + $stage_manipulator->addPackage([ + 'type' => 'package', + 'name' => 'dummy/depends-on-composer-patches', + 'description' => 'A dummy package depending on cweagans/composer-patches', + 'version' => '1.0.0', + 'require' => ['cweagans/composer-patches' => '*'], + ]); + } + + $stage = $this->createStage(); + $stage->create(); + $stage_dir = $stage->getStageDirectory(); + $stage->require(['drupal/core:9.8.1']); + + try { + $stage->apply(); + // If we didn't get an exception, ensure we didn't expect any errors. + $this->assertSame([], $expected_results); + } + catch (StageEventException $e) { + $this->assertNotEmpty($expected_results); + $this->assertValidationResultsEqual($expected_results, $e->event->getResults(), NULL, $stage_dir); + } + } + + /** + * Tests that validation errors can carry links to help. + * + * @param int $in_active + * Whether patcher is installed in active. + * @param int $in_stage + * Whether patcher is installed in stage. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param string[] $help_page_sections + * An associative array of fragments (anchors) in the online help. The keys + * should be the numeric indices of the validation result messages which + * should link to those fragments. + * + * @dataProvider providerErrorDuringPreApply + */ + public function testErrorDuringPreApplyWithHelp(int $in_active, int $in_stage, array $expected_results, array $help_page_sections): void { + $this->enableModules(['help']); + + foreach ($expected_results as $result_index => $result) { + $messages = $result->messages; + + foreach ($messages as $message_index => $message) { + if ($help_page_sections[$message_index]) { + // Get the link to the online documentation for the error message. + $url = Url::fromRoute('help.page', ['name' => 'package_manager']) + ->setOption('fragment', $help_page_sections[$message_index]) + ->toString(); + // Reformat the provided results so that they all have the link to the + // online documentation appended to them. + $messages[$message_index] = t('@message See <a href=":url">the help page</a> for information on how to resolve the problem.', ['@message' => $message, ':url' => $url]); + } + } + $expected_results[$result_index] = ValidationResult::createError($messages, $result->summary); + } + $this->testErrorDuringPreApply($in_active, $in_stage, $expected_results); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php new file mode 100644 index 00000000000..cf3c0638ed7 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerPluginsValidatorTest.php @@ -0,0 +1,405 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerPluginsValidator + * @group package_manager + * @internal + */ +class ComposerPluginsValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests `config.allow-plugins: true` fails validation during pre-create. + */ + public function testInsecureConfigurationFailsValidationPreCreate(): void { + $active_manipulator = new ActiveFixtureManipulator(); + $active_manipulator->addConfig(['allow-plugins' => TRUE]); + $active_manipulator->commitChanges(); + + $expected_results = [ + ValidationResult::createError( + [ + new TranslatableMarkup('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.'), + ], + ), + ]; + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests `config.allow-plugins: true` fails validation during pre-apply. + */ + public function testInsecureConfigurationFailsValidationPreApply(): void { + $stage_manipulator = $this->getStageFixtureManipulator(); + $stage_manipulator->addConfig(['allow-plugins' => TRUE]); + + $expected_results = [ + ValidationResult::createError( + [ + new TranslatableMarkup('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.'), + ], + ), + ]; + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Tests composer plugins are validated during pre-create. + * + * @dataProvider providerSimpleValidCases + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationDuringPreCreate(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $active_manipulator = new ActiveFixtureManipulator(); + if ($composer_config_to_add) { + $active_manipulator->addConfig($composer_config_to_add); + } + foreach ($packages_to_add as $package) { + $active_manipulator->addPackage($package); + } + $active_manipulator->commitChanges(); + + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests composer plugins are validated during pre-apply. + * + * @dataProvider providerSimpleValidCases + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationDuringPreApply(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $stage_manipulator = $this->getStageFixtureManipulator(); + if ($composer_config_to_add) { + $stage_manipulator->addConfig($composer_config_to_add); + } + foreach ($packages_to_add as $package) { + $stage_manipulator->addPackage($package); + } + + // Ensure \Drupal\package_manager\Validator\SupportedReleaseValidator does + // not complain. + $release_fixture_folder = __DIR__ . '/../../fixtures/release-history'; + $this->setReleaseMetadata([ + 'semver_test' => "$release_fixture_folder/semver_test.1.1.xml", + ]); + + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Tests adding a plugin that's not allowed by the allow-plugins config. + * + * The exception that this test looks for is not necessarily triggered by + * ComposerPluginsValidator; Composer will exit with an error if there is an + * installed plugin that is not allowed by the `allow-plugins` config. In + * practice, this means that whichever validator is the first one to do a + * Composer operation (via ComposerInspector) will get the exception -- it + * may or may not be ComposerPluginsValidator. + * + * This test is here to ensure that Composer's behavior remains consistent, + * even if we're not explicitly testing ComposerPluginsValidator here. + */ + public function testAddDisallowedPlugin(): void { + $this->getStageFixtureManipulator() + ->addPackage([ + 'name' => 'composer/plugin-c', + 'version' => '16.4', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ]); + + $expected_message = "composer/plugin-c contains a Composer plugin which is blocked by your allow-plugins config."; + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + try { + // We are trying to add package plugin-c but not allowing it in config, + // so we expect the operation to fail on PreApplyEvent. + $stage->apply(); + } + catch (StageEventException $e) { + // Processing is required because the error message we get from Composer + // contains multiple white spaces at the start or end of line. + $this->assertStringContainsString($expected_message, preg_replace('/\s\s+/', '', $e->getMessage())); + $this->assertInstanceOf(PreApplyEvent::class, $e->event); + } + } + + /** + * Tests additional composer plugins can be trusted during pre-create. + * + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationAfterTrustingDuringPreCreate(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $expected_results_without_composer_plugin_violations = array_filter( + $expected_results, + fn (ValidationResult $v) => !$v->summary || !str_contains(strtolower($v->summary->getUntranslatedString()), 'unsupported composer plugin'), + ); + + // Trust all added packages. + $this->config('package_manager.settings') + ->set('additional_trusted_composer_plugins', array_map(fn (array $package) => $package['name'], $packages_to_add)) + ->save(); + + // Reuse the test logic that does not trust additional packages, but with + // updated expected results. + $this->testValidationDuringPreCreate($composer_config_to_add, $packages_to_add, $expected_results_without_composer_plugin_violations); + } + + /** + * Tests additional composer plugins can be trusted during pre-apply. + * + * @dataProvider providerSimpleInvalidCases + * @dataProvider providerComplexInvalidCases + */ + public function testValidationAfterTrustingDuringPreApply(array $composer_config_to_add, array $packages_to_add, array $expected_results): void { + $expected_results_without_composer_plugin_violations = array_filter( + $expected_results, + fn (ValidationResult $v) => !$v->summary || !str_contains(strtolower($v->summary->getUntranslatedString()), 'unsupported composer plugin'), + ); + + // Trust all added packages. + $this->config('package_manager.settings') + ->set('additional_trusted_composer_plugins', array_map(fn (array $package) => $package['name'], $packages_to_add)) + ->save(); + + // Reuse the test logic that does not trust additional packages, but with + // updated expected results. + $this->testValidationDuringPreApply($composer_config_to_add, $packages_to_add, $expected_results_without_composer_plugin_violations); + } + + /** + * Generates simple test cases. + * + * @return \Generator + */ + public static function providerSimpleValidCases(): \Generator { + yield 'no composer plugins' => [ + [], + [ + [ + 'name' => "drupal/semver_test", + 'version' => '8.1.0', + 'type' => 'drupal-module', + ], + ], + [], + ]; + + yield 'another supported composer plugin' => [ + [ + 'allow-plugins.drupal/core-vendor-hardening' => TRUE, + ], + [ + [ + 'name' => 'drupal/core-vendor-hardening', + 'version' => '9.8.0', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [], + ]; + + yield 'a supported composer plugin for which any version is supported: party like it is Drupal 99!' => [ + [ + 'allow-plugins.drupal/core-composer-scaffold' => TRUE, + ], + [ + [ + 'name' => 'drupal/core-composer-scaffold', + 'version' => '99.0.0', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [], + ]; + + yield 'one UNsupported but disallowed plugin — pretty package name' => [ + [ + 'allow-plugins.composer/plugin-a' => FALSE, + ], + [ + [ + 'name' => 'composer/plugin-a', + 'version' => '6.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [], + ]; + + yield 'one UNsupported but disallowed plugin — normalized package name' => [ + [ + 'allow-plugins.composer/plugin-b' => FALSE, + ], + [ + [ + 'name' => 'composer/plugin-b', + 'version' => '20.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [], + ]; + } + + /** + * Generates simple invalid test cases. + * + * @return \Generator + */ + public static function providerSimpleInvalidCases(): \Generator { + yield 'one UNsupported composer plugin — pretty package name' => [ + [ + 'allow-plugins.not-cweagans/not-composer-patches' => TRUE, + ], + [ + [ + 'name' => 'not-cweagans/not-composer-patches', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + 'version' => '6.1', + 'type' => 'composer-plugin', + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>not-cweagans/not-composer-patches</code>'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + + yield 'one UNsupported composer plugin — normalized package name' => [ + [ + 'allow-plugins.also-not-cweagans/also-not-composer-patches' => TRUE, + ], + [ + [ + 'name' => 'also-not-cweagans/also-not-composer-patches', + 'version' => '20.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>also-not-cweagans/also-not-composer-patches</code>'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + + yield 'one supported composer plugin but incompatible version — newer version' => [ + [ + 'allow-plugins.phpstan/extension-installer' => TRUE, + ], + [ + [ + 'name' => 'phpstan/extension-installer', + 'version' => '20.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>phpstan/extension-installer</code> is supported, but only version <code>^1.1</code>, found <code>20.1</code>.'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + + yield 'one supported composer plugin but incompatible version — older version' => [ + [ + 'allow-plugins.dealerdirect/phpcodesniffer-composer-installer' => TRUE, + ], + [ + [ + 'name' => 'dealerdirect/phpcodesniffer-composer-installer', + 'version' => '0.6.1', + 'type' => 'composer-plugin', + 'require' => ['composer-plugin-api' => '*'], + 'extra' => ['class' => 'AnyClass'], + ], + ], + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>dealerdirect/phpcodesniffer-composer-installer</code> is supported, but only version <code>^0.7.1 || ^1.0.0</code>, found <code>0.6.1</code>.'), + ], + new TranslatableMarkup('An unsupported Composer plugin was detected.'), + ), + ], + ]; + } + + /** + * Generates complex invalid test cases based on the simple test cases. + * + * @return \Generator + */ + public static function providerComplexInvalidCases(): \Generator { + $valid_cases = iterator_to_array(static::providerSimpleValidCases()); + $invalid_cases = iterator_to_array(static::providerSimpleInvalidCases()); + $all_config = NestedArray::mergeDeepArray( + // First key-value pair for each simple test case: the packages it adds. + array_map(fn (array $c) => $c[0], $valid_cases + $invalid_cases) + ); + $all_packages = NestedArray::mergeDeepArray( + // Second key-value pair for each simple test case: the packages it adds. + array_map(fn (array $c) => $c[1], $valid_cases + $invalid_cases) + ); + + yield 'complex combination' => [ + $all_config, + $all_packages, + [ + ValidationResult::createError( + [ + new TranslatableMarkup('<code>not-cweagans/not-composer-patches</code>'), + new TranslatableMarkup('<code>also-not-cweagans/also-not-composer-patches</code>'), + new TranslatableMarkup('<code>phpstan/extension-installer</code> is supported, but only version <code>^1.1</code>, found <code>20.1</code>.'), + new TranslatableMarkup('<code>dealerdirect/phpcodesniffer-composer-installer</code> is supported, but only version <code>^0.7.1 || ^1.0.0</code>, found <code>0.6.1</code>.'), + ], + new TranslatableMarkup('Unsupported Composer plugins were detected.'), + ), + ], + ]; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerValidatorTest.php new file mode 100644 index 00000000000..7320d7a385b --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ComposerValidatorTest.php @@ -0,0 +1,173 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\ComposerValidator + * @group package_manager + * @internal + */ +class ComposerValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testComposerSettingsValidation(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerComposerSettingsValidation(): array { + $summary = t("Composer settings don't satisfy Package Manager's requirements."); + + $secure_http_error = ValidationResult::createError([ + t('HTTPS must be enabled for Composer downloads. See <a href="https://getcomposer.org/doc/06-config.md#secure-http">the Composer documentation</a> for more information.'), + ], $summary); + $tls_error = ValidationResult::createError([ + t('TLS must be enabled for HTTPS Composer downloads. See <a href="https://getcomposer.org/doc/06-config.md#disable-tls">the Composer documentation</a> for more information.'), + t('You should also check the value of <code>secure-http</code> and make sure that it is set to <code>true</code> or not set at all.'), + ], $summary); + + return [ + 'secure-http set to FALSE' => [ + [ + 'secure-http' => FALSE, + ], + [$secure_http_error], + ], + 'secure-http explicitly set to TRUE' => [ + [ + 'secure-http' => TRUE, + ], + [], + ], + 'secure-http implicitly set to TRUE' => [ + [ + 'extra.unrelated' => TRUE, + ], + [], + ], + 'disable-tls set to TRUE' => [ + [ + 'disable-tls' => TRUE, + ], + [$tls_error], + ], + 'disable-tls implicitly set to FALSE' => [ + [ + 'extra.unrelated' => TRUE, + ], + [], + ], + 'explicitly set disable-tls to FALSE' => [ + [ + 'disable-tls' => FALSE, + ], + [], + ], + 'disable-tls set to TRUE + secure-http set to TRUE, message only for TLS, secure-http overridden' => [ + [ + 'disable-tls' => TRUE, + 'secure-http' => TRUE, + ], + [$tls_error], + ], + 'disable-tls set to TRUE + secure-http set to FALSE, message only for TLS' => [ + [ + 'disable-tls' => TRUE, + 'secure-http' => FALSE, + ], + [$tls_error], + ], + ]; + } + + /** + * Tests that Composer's settings are validated. + * + * @param array $config + * The config to set. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results, if any. + * + * @dataProvider providerComposerSettingsValidation + */ + public function testComposerSettingsValidation(array $config, array $expected_results): void { + (new ActiveFixtureManipulator())->addConfig($config)->commitChanges(); + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests that Composer's settings are validated during pre-apply. + * + * @param array $config + * The config to set. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results, if any. + * + * @dataProvider providerComposerSettingsValidation + */ + public function testComposerSettingsValidationDuringPreApply(array $config, array $expected_results): void { + $this->getStageFixtureManipulator()->addConfig($config); + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Data provider for ::testLinkToOnlineHelp(). + * + * @return array[] + * The test cases. + */ + public static function providerLinkToOnlineHelp(): array { + return [ + 'TLS disabled' => [ + ['disable-tls' => TRUE], + [ + t('TLS must be enabled for HTTPS Composer downloads. See <a href="/admin/help/package_manager#package-manager-requirements">the help page</a> for more information on how to configure Composer to download packages securely.'), + t('You should also check the value of <code>secure-http</code> and make sure that it is set to <code>true</code> or not set at all.'), + ], + ], + 'secure-http is off' => [ + ['secure-http' => FALSE], + [ + t('HTTPS must be enabled for Composer downloads. See <a href="/admin/help/package_manager#package-manager-requirements">the help page</a> for more information on how to configure Composer to download packages securely.'), + ], + ], + ]; + } + + /** + * Tests that invalid configuration links to online help, if available. + * + * @param array $config + * The Composer configuration to set. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_messages + * The expected validation error messages. + * + * @dataProvider providerLinkToOnlineHelp + */ + public function testLinkToOnlineHelp(array $config, array $expected_messages): void { + $this->enableModules(['help']); + (new ActiveFixtureManipulator())->addConfig($config)->commitChanges(); + + $result = ValidationResult::createError($expected_messages, t("Composer settings don't satisfy Package Manager's requirements.")); + $this->assertStatusCheckResults([$result]); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void { + // Ensure that any warnings arising from Composer settings (which we expect + // in this test) will not fail the test during tear-down. + $this->failureLogger->reset(); + parent::tearDown(); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php new file mode 100644 index 00000000000..a7ea9f40512 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php @@ -0,0 +1,189 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\Component\Utility\Bytes; +use Drupal\package_manager\Validator\DiskSpaceValidator; + +/** + * @covers \Drupal\package_manager\Validator\DiskSpaceValidator + * @group package_manager + * @internal + */ +class DiskSpaceValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testDiskSpaceValidation(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerDiskSpaceValidation(): array { + // @see \Drupal\Tests\package_manager\Traits\ValidationTestTrait::resolvePlaceholdersInArrayValuesWithRealPaths() + $root = '<PROJECT_ROOT>'; + $vendor = '<VENDOR_DIR>'; + + $root_insufficient = t('Drupal root filesystem "<PROJECT_ROOT>" has insufficient space. There must be at least 1024 megabytes free.'); + $vendor_insufficient = t('Vendor filesystem "<VENDOR_DIR>" has insufficient space. There must be at least 1024 megabytes free.'); + $temp_insufficient = t('Directory "temp" has insufficient space. There must be at least 1024 megabytes free.'); + $summary = t("There is not enough disk space to create a stage directory."); + + return [ + 'shared, vendor and temp sufficient, root insufficient' => [ + TRUE, + [ + $root => '1M', + $vendor => '2G', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'shared, root and vendor insufficient, temp sufficient' => [ + TRUE, + [ + $root => '1M', + $vendor => '2M', + 'temp' => '2G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'shared, vendor and root sufficient, temp insufficient' => [ + TRUE, + [ + $root => '2G', + $vendor => '4G', + 'temp' => '1M', + ], + [ + ValidationResult::createError([$temp_insufficient]), + ], + ], + 'shared, root and temp insufficient, vendor sufficient' => [ + TRUE, + [ + $root => '1M', + $vendor => '2G', + 'temp' => '2M', + ], + [ + ValidationResult::createError([ + $root_insufficient, + $temp_insufficient, + ], $summary), + ], + ], + 'not shared, root insufficient, vendor and temp sufficient' => [ + FALSE, + [ + $root => '5M', + $vendor => '1G', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$root_insufficient]), + ], + ], + 'not shared, vendor insufficient, root and temp sufficient' => [ + FALSE, + [ + $root => '2G', + $vendor => '10M', + 'temp' => '4G', + ], + [ + ValidationResult::createError([$vendor_insufficient]), + ], + ], + 'not shared, root and vendor sufficient, temp insufficient' => [ + FALSE, + [ + $root => '1G', + $vendor => '2G', + 'temp' => '3M', + ], + [ + ValidationResult::createError([$temp_insufficient]), + ], + ], + 'not shared, root and vendor insufficient, temp sufficient' => [ + FALSE, + [ + $root => '500M', + $vendor => '75M', + 'temp' => '2G', + ], + [ + ValidationResult::createError([ + $root_insufficient, + $vendor_insufficient, + ], $summary), + ], + ], + ]; + } + + /** + * Tests disk space validation. + * + * @param bool $shared_disk + * Whether the root and vendor directories are on the same logical disk. + * @param array $free_space + * The free space that should be reported for various paths. The keys + * are the paths, and the values are the free space that should be reported, + * in a format that can be parsed by + * \Drupal\Component\Utility\Bytes::toNumber(). + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerDiskSpaceValidation + */ + public function testDiskSpaceValidation(bool $shared_disk, array $free_space, array $expected_results): void { + $free_space = array_flip($this->resolvePlaceholdersInArrayValuesWithRealPaths(array_flip($free_space))); + + /** @var \Drupal\Tests\package_manager\Kernel\TestDiskSpaceValidator $validator */ + $validator = $this->container->get(DiskSpaceValidator::class); + $validator->sharedDisk = $shared_disk; + $validator->freeSpace = array_map([Bytes::class, 'toNumber'], $free_space); + + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests disk space validation during pre-apply. + * + * @param bool $shared_disk + * Whether the root and vendor directories are on the same logical disk. + * @param array $free_space + * The free space that should be reported for various paths. The keys + * are the paths, and the values are the free space that should be reported, + * in a format that can be parsed by + * \Drupal\Component\Utility\Bytes::toNumber(). + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerDiskSpaceValidation + */ + public function testDiskSpaceValidationDuringPreApply(bool $shared_disk, array $free_space, array $expected_results): void { + $free_space = array_flip($this->resolvePlaceholdersInArrayValuesWithRealPaths(array_flip($free_space))); + + $this->addEventTestListener(function () use ($shared_disk, $free_space): void { + /** @var \Drupal\Tests\package_manager\Kernel\TestDiskSpaceValidator $validator */ + $validator = $this->container->get(DiskSpaceValidator::class); + $validator->sharedDisk = $shared_disk; + $validator->freeSpace = array_map([Bytes::class, 'toNumber'], $free_space); + }); + + $this->assertResults($expected_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php new file mode 100644 index 00000000000..7583f63d340 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php @@ -0,0 +1,242 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @covers \Drupal\package_manager\Validator\DuplicateInfoFileValidator + * @group package_manager + * @internal + */ +class DuplicateInfoFileValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testDuplicateInfoFilesInStage. + * + * @return mixed[][] + * The test cases. + */ + public static function providerDuplicateInfoFilesInStage(): array { + return [ + 'Duplicate info.yml files in stage' => [ + [ + '/module.info.yml', + ], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 2 instances of module.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + // Duplicate files in stage but having different extension which we don't + // care about. + 'Duplicate info files in stage' => [ + [ + '/my_file.info', + ], + [ + '/my_file.info', + '/modules/my_file.info', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests/fixtures folder' => [ + [ + '/tests/fixtures/module.info.yml', + ], + [ + '/tests/fixtures/module.info.yml', + '/modules/module.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests/modules folder' => [ + [ + '/tests/modules/module.info.yml', + ], + [ + '/tests/modules/module.info.yml', + '/modules/module.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests/themes folder' => [ + [ + '/tests/themes/theme.info.yml', + ], + [ + '/tests/themes/theme.info.yml', + '/themes/theme.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage with one file in tests/profiles folder' => [ + [ + '/tests/profiles/profile.info.yml', + ], + [ + '/tests/profiles/profile.info.yml', + '/profiles/profile.info.yml', + ], + [], + ], + 'Duplicate info.yml files in stage not present in active' => [ + [], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 2 instances of module.info.yml. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + 'Duplicate info.yml files in active' => [ + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + '/module.info.yml', + ], + [], + ], + 'Same number of info.yml files in active and stage' => [ + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [ + '/module.info.yml', + '/modules/module.info.yml', + ], + [], + ], + 'Multiple duplicate info.yml files in stage' => [ + [ + '/modules/module1/module1.info.yml', + '/modules/module2/module2.info.yml', + ], + [ + '/modules/module1/module1.info.yml', + '/modules/module2/module2.info.yml', + '/modules/foo/module1.info.yml', + '/modules/bar/module2.info.yml', + '/modules/baz/module2.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 3 instances of module2.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.'), + ]), + ValidationResult::createError([ + t('The stage directory has 2 instances of module1.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + 'Multiple duplicate info.yml files in stage not present in active' => [ + [], + [ + '/modules/module1/module1.info.yml', + '/modules/module2/module2.info.yml', + '/modules/foo/module1.info.yml', + '/modules/bar/module2.info.yml', + '/modules/baz/module2.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 3 instances of module2.info.yml. This likely indicates that a duplicate extension was installed.'), + ]), + ValidationResult::createError([ + t('The stage directory has 2 instances of module1.info.yml. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + 'Multiple duplicate info.yml files in stage with one info.yml file not present in active' => [ + [ + '/modules/module1/module1.info.yml', + ], + [ + '/modules/module1/module1.info.yml', + '/modules/module2/module2.info.yml', + '/modules/foo/module1.info.yml', + '/modules/bar/module2.info.yml', + '/modules/baz/module2.info.yml', + ], + [ + ValidationResult::createError([ + t('The stage directory has 3 instances of module2.info.yml. This likely indicates that a duplicate extension was installed.'), + ]), + ValidationResult::createError([ + t('The stage directory has 2 instances of module1.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.'), + ]), + ], + ], + ]; + } + + /** + * Tests that duplicate info.yml in stage raise an error. + * + * @param string[] $active_info_files + * An array of info.yml files in active directory. + * @param string[] $stage_info_files + * An array of info.yml files in stage directory. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * An array of expected results. + * + * @dataProvider providerDuplicateInfoFilesInStage + */ + public function testDuplicateInfoFilesInStage(array $active_info_files, array $stage_info_files, array $expected_results): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['composer/semver:^3']); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + $stage_dir = $stage->getStageDirectory(); + foreach ($active_info_files as $active_info_file) { + $this->createFileAtPath($active_dir, $active_info_file); + } + foreach ($stage_info_files as $stage_info_file) { + $this->createFileAtPath($stage_dir, $stage_info_file); + } + try { + $stage->apply(); + $this->assertEmpty($expected_results); + } + catch (StageEventException $e) { + $this->assertNotEmpty($expected_results); + $this->assertValidationResultsEqual($expected_results, $e->event->getResults()); + } + } + + /** + * Creates the file at the root directory. + * + * @param string $root_directory + * The base directory in which the file will be created. + * @param string $file_path + * The path of the file to create. + */ + private function createFileAtPath(string $root_directory, string $file_path): void { + $parts = explode(DIRECTORY_SEPARATOR, $file_path); + $filename = array_pop($parts); + $file_dir = str_replace($filename, '', $file_path); + $fs = new Filesystem(); + if (!file_exists($file_dir)) { + $fs->mkdir($root_directory . $file_dir); + } + file_put_contents($root_directory . $file_path, "name: SOME MODULE\ntype: module\n"); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php new file mode 100644 index 00000000000..c17cccf7a7a --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/EnabledExtensionsValidatorTest.php @@ -0,0 +1,163 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\Extension\Extension; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\package_manager\Traits\ComposerInstallersTrait; + +/** + * @covers \Drupal\package_manager\Validator\EnabledExtensionsValidator + * @group package_manager + * @internal + */ +class EnabledExtensionsValidatorTest extends PackageManagerKernelTestBase { + + use ComposerInstallersTrait; + + /** + * Data provider for testExtensionRemoved(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerExtensionRemoved(): array { + $summary = t('The update cannot proceed because the following enabled Drupal extension was removed during the update.'); + return [ + 'module' => [ + [ + [ + 'name' => 'drupal/test_module2', + 'version' => '1.3.1', + 'type' => 'drupal-module', + ], + ], + [ + ValidationResult::createError([t("'test_module2' module (provided by <code>drupal/test_module2</code>)")], $summary), + ], + ], + 'module and theme' => [ + [ + [ + 'name' => 'drupal/test_module1', + 'version' => '1.3.1', + 'type' => 'drupal-module', + ], + [ + 'name' => 'drupal/test_theme', + 'version' => '1.3.1', + 'type' => 'drupal-theme', + ], + ], + [ + ValidationResult::createError([ + t("'test_module1' module (provided by <code>drupal/test_module1</code>)"), + t("'test_theme' theme (provided by <code>drupal/test_theme</code>)"), + ], t('The update cannot proceed because the following enabled Drupal extensions were removed during the update.')), + ], + ], + 'profile' => [ + [ + [ + 'name' => 'drupal/test_profile', + 'version' => '1.3.1', + 'type' => 'drupal-profile', + ], + ], + [ + ValidationResult::createError([t("'test_profile' profile (provided by <code>drupal/test_profile</code>)")], $summary), + ], + ], + 'theme' => [ + [ + [ + 'name' => 'drupal/test_theme', + 'version' => '1.3.1', + 'type' => 'drupal-theme', + ], + ], + [ + ValidationResult::createError([t("'test_theme' theme (provided by <code>drupal/test_theme</code>)")], $summary), + ], + ], + ]; + } + + /** + * Tests that error is raised if Drupal modules, profiles or themes are removed. + * + * @param array $packages + * Packages that will be added to the active directory, and removed from the + * stage directory. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerExtensionRemoved + */ + public function testExtensionRemoved(array $packages, array $expected_results): void { + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + $this->installComposerInstallers($project_root); + + $active_manipulator = new ActiveFixtureManipulator(); + $stage_manipulator = $this->getStageFixtureManipulator(); + foreach ($packages as $package) { + $active_manipulator->addPackage($package, FALSE, TRUE); + $stage_manipulator->removePackage($package['name']); + } + $active_manipulator->commitChanges(); + + foreach ($packages as $package) { + $extension_name = str_replace('drupal/', '', $package['name']); + $extension = self::createExtension($project_root, $package['type'], $extension_name); + + if ($extension->getType() === 'theme') { + /** @var \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler */ + $theme_handler = $this->container->get('theme_handler'); + $theme_handler->addTheme($extension); + $this->assertArrayHasKey($extension_name, $theme_handler->listInfo()); + } + else { + /** @var \Drupal\Core\Extension\ModuleHandlerInterface $module_handler */ + $module_handler = $this->container->get('module_handler'); + $module_list = $module_handler->getModuleList(); + $module_list[$extension_name] = $extension; + $module_handler->setModuleList($module_list); + $this->assertArrayHasKey($extension_name, $module_handler->getModuleList()); + } + } + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Returns a mocked extension object for a package. + * + * @param string $project_root + * The project root directory. + * @param string $package_type + * The package type (e.g., `drupal-module` or `drupal-theme`). + * @param string $extension_name + * The name of the extension. + * + * @return \Drupal\Core\Extension\Extension + * An extension object. + */ + private static function createExtension(string $project_root, string $package_type, string $extension_name): Extension { + $type = match ($package_type) { + 'drupal-theme' => 'theme', + 'drupal-profile' => 'profile', + default => 'module', + }; + $subdirectory = match ($type) { + 'theme' => 'themes', + 'profile' => 'profiles', + 'module' => 'modules', + }; + return new Extension($project_root, $type, "$subdirectory/contrib/$extension_name/$extension_name.info.yml"); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/EnvironmentSupportValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/EnvironmentSupportValidatorTest.php new file mode 100644 index 00000000000..2e9a3909bf9 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/EnvironmentSupportValidatorTest.php @@ -0,0 +1,81 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\EnvironmentSupportValidator; + +/** + * @covers \Drupal\package_manager\Validator\EnvironmentSupportValidator + * @group package_manager + * @internal + */ +class EnvironmentSupportValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests handling of an invalid URL in the environment support variable. + */ + public function testInvalidUrl(): void { + putenv(EnvironmentSupportValidator::VARIABLE_NAME . '=broken/url.org'); + + $result = ValidationResult::createError([ + t('Package Manager is not supported by your environment.'), + ]); + foreach ([PreCreateEvent::class, StatusCheckEvent::class] as $event_class) { + $this->assertEventPropagationStopped($event_class, [$this->container->get(EnvironmentSupportValidator::class), 'validate']); + } + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests an invalid URL in the environment support variable during pre-apply. + */ + public function testInvalidUrlDuringPreApply(): void { + $this->addEventTestListener(function (): void { + putenv(EnvironmentSupportValidator::VARIABLE_NAME . '=broken/url.org'); + }); + + $result = ValidationResult::createError([ + t('Package Manager is not supported by your environment.'), + ]); + + $this->assertEventPropagationStopped(PreApplyEvent::class, [$this->container->get(EnvironmentSupportValidator::class), 'validate']); + $this->assertResults([$result], PreApplyEvent::class); + } + + /** + * Tests that the validation message links to the provided URL. + */ + public function testValidUrl(): void { + $url = 'http://www.example.com'; + putenv(EnvironmentSupportValidator::VARIABLE_NAME . '=' . $url); + + $result = ValidationResult::createError([ + t('<a href=":url">Package Manager is not supported by your environment.</a>', [':url' => $url]), + ]); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that the validation message links to the provided URL during pre-apply. + */ + public function testValidUrlDuringPreApply(): void { + $url = 'http://www.example.com'; + $this->addEventTestListener(function () use ($url): void { + putenv(EnvironmentSupportValidator::VARIABLE_NAME . '=' . $url); + }); + + $result = ValidationResult::createError([ + t('<a href=":url">Package Manager is not supported by your environment.</a>', [':url' => $url]), + ]); + $this->assertResults([$result], PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php b/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php new file mode 100644 index 00000000000..a41de4a9220 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Exception\StageFailureMarkerException; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; + +/** + * @coversDefaultClass \Drupal\package_manager\FailureMarker + * @group package_manager + * @internal + */ +class FailureMarkerTest extends PackageManagerKernelTestBase { + + /** + * @covers ::getMessage + * @testWith [true] + * [false] + */ + public function testGetMessageWithoutThrowable(bool $include_backtrace): void { + $failure_marker = $this->container->get(FailureMarker::class); + $failure_marker->write($this->createStage(), t('Disastrous catastrophe!')); + + $this->assertMatchesRegularExpression('/^Disastrous catastrophe!$/', $failure_marker->getMessage($include_backtrace)); + } + + /** + * @covers ::getMessage + * @testWith [true] + * [false] + */ + public function testGetMessageWithThrowable(bool $include_backtrace): void { + $failure_marker = $this->container->get(FailureMarker::class); + $failure_marker->write($this->createStage(), t('Disastrous catastrophe!'), new \Exception('Witchcraft!')); + + $expected_pattern = $include_backtrace + ? <<<REGEXP +/^Disastrous catastrophe! Caused by Exception, with this message: Witchcraft! +Backtrace: +#0 .*FailureMarkerTest->testGetMessageWithThrowable\(true\) +#1 .* +#2 .* +#3 .*/ +REGEXP + : '/^Disastrous catastrophe! Caused by Exception, with this message: Witchcraft!$/'; + $this->assertMatchesRegularExpression( + $expected_pattern, + $failure_marker->getMessage($include_backtrace) + ); + } + + /** + * Tests that an exception is thrown if the marker file contains invalid YAML. + * + * @covers ::assertNotExists + */ + public function testExceptionForInvalidYaml(): void { + $failure_marker = $this->container->get(FailureMarker::class); + // Write the failure marker with invalid YAML. + file_put_contents($failure_marker->getPath(), 'message : something message : something1'); + + $this->expectException(StageFailureMarkerException::class); + $this->expectExceptionMessage('Failure marker file exists but cannot be decoded.'); + $failure_marker->assertNotExists(); + } + + /** + * Tests that the failure marker can contain an exception message. + * + * @covers ::assertNotExists + */ + public function testAssertNotExists(): void { + $failure_marker = $this->container->get(FailureMarker::class); + $failure_marker->write($this->createStage(), t('Something wicked occurred here.'), new \Exception('Witchcraft!')); + + $this->expectException(StageFailureMarkerException::class); + $this->expectExceptionMessageMatches('/^Something wicked occurred here. Caused by Exception, with this message: Witchcraft!\nBacktrace:\n#0 .*/'); + $failure_marker->assertNotExists(); + } + + /** + * @covers ::getSubscribedEvents + * @covers ::excludeMarkerFile + */ + public function testMarkerFileIsExcluded(): void { + $event = new CollectPathsToExcludeEvent( + $this->createStage(), + $this->container->get(PathLocator::class), + $this->container->get(PathFactoryInterface::class), + ); + $this->container->get('event_dispatcher')->dispatch($event); + $this->assertContains('PACKAGE_MANAGER_FAILURE.yml', $event->getAll()); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php b/core/modules/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php new file mode 100644 index 00000000000..e7ed43228bb --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php @@ -0,0 +1,161 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\PathLocator; +use Symfony\Component\Process\Process; + +/** + * Test that the 'fake-site' fixture is a valid starting point. + * + * @group package_manager + * @internal + */ +class FakeSiteFixtureTest extends PackageManagerKernelTestBase { + + /** + * Tests the complete stage life cycle using the 'fake-site' fixture. + */ + public function testLifeCycle(): void { + $this->assertStatusCheckResults([]); + $this->assertResults([]); + // Ensure there are no validation errors after the stage lifecycle has been + // completed. + $this->assertStatusCheckResults([]); + } + + /** + * Tests calls to ComposerInspector class methods. + */ + public function testCallToComposerInspectorMethods(): void { + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + $list = $inspector->getInstalledPackagesList($active_dir); + $this->assertNull($list->getPackageByDrupalProjectName('any_random_name')); + $this->assertFalse(isset($list['drupal/any_random_name'])); + } + + /** + * Tests if `setVersion` can be called on all packages in the fixture. + * + * @see \Drupal\fixture_manipulator\FixtureManipulator::setVersion() + */ + public function testCallToSetVersion(): void { + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + $installed_packages = $inspector->getInstalledPackagesList($active_dir); + foreach (self::getExpectedFakeSitePackages() as $package_name) { + $this->assertArrayHasKey($package_name, $installed_packages); + $this->assertSame($installed_packages[$package_name]->version, '9.8.0'); + (new ActiveFixtureManipulator()) + ->setVersion($package_name, '11.1.0') + ->commitChanges(); + $list = $inspector->getInstalledPackagesList($active_dir); + $this->assertSame($list[$package_name]?->version, '11.1.0'); + } + } + + /** + * Tests if `removePackage` can be called on all packages in the fixture. + * + * @covers \Drupal\fixture_manipulator\FixtureManipulator::removePackage + */ + public function testCallToRemovePackage(): void { + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get(ComposerInspector::class); + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + $expected_packages = self::getExpectedFakeSitePackages(); + $actual_packages = array_keys($inspector->getInstalledPackagesList($active_dir)->getArrayCopy()); + sort($actual_packages); + $this->assertSame($expected_packages, $actual_packages); + foreach (self::getExpectedFakeSitePackages() as $package_name) { + (new ActiveFixtureManipulator()) + ->removePackage($package_name, $package_name === 'drupal/core-dev') + ->commitChanges(); + array_shift($expected_packages); + $actual_package_names = array_keys($inspector->getInstalledPackagesList($active_dir)->getArrayCopy()); + sort($actual_package_names); + $this->assertSame($expected_packages, $actual_package_names); + } + + } + + /** + * Checks that the expected packages are installed in the fake site fixture. + */ + public function testExpectedPackages(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $installed_packages = $this->container->get(ComposerInspector::class) + ->getInstalledPackagesList($project_root) + ->getArrayCopy(); + ksort($installed_packages); + $this->assertSame($this->getExpectedFakeSitePackages(), array_keys($installed_packages)); + } + + /** + * Gets the expected packages in the `fake_site` fixture. + * + * @return string[] + * The package names. + */ + private static function getExpectedFakeSitePackages(): array { + $packages = [ + 'drupal/core', + 'drupal/core-recommended', + 'drupal/core-dev', + ]; + sort($packages); + return $packages; + } + + /** + * Tests that Composer show command can be used on the fixture. + */ + public function testComposerShow(): void { + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + (new ActiveFixtureManipulator()) + ->addPackage([ + 'type' => 'package', + 'version' => '1.2.3', + 'name' => 'any-org/any-package', + ]) + ->commitChanges(); + $process = new Process(['composer', 'show', '--format=json'], $active_dir); + $process->run(); + if ($error = $process->getErrorOutput()) { + $this->fail('Process error: ' . $error); + } + $output = json_decode($process->getOutput(), TRUE); + $package_names = array_map(fn (array $package) => $package['name'], $output['installed']); + $this->assertTrue(asort($package_names)); + $this->assertSame(['any-org/any-package', 'drupal/core', 'drupal/core-dev', 'drupal/core-recommended'], $package_names); + $list = $this->container->get(ComposerInspector::class)->getInstalledPackagesList($active_dir); + $list_packages_names = array_keys($list->getArrayCopy()); + $this->assertSame(['any-org/any-package', 'drupal/core', 'drupal/core-dev', 'drupal/core-recommended'], $list_packages_names); + } + + /** + * Tests that the fixture passes `composer validate`. + */ + public function testComposerValidate(): void { + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + $process = new Process([ + 'composer', + 'validate', + '--check-lock', + '--with-dependencies', + '--no-interaction', + '--ansi', + '--no-cache', + ], $active_dir); + $process->mustRun(); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/FixtureManipulatorTest.php b/core/modules/package_manager/tests/src/Kernel/FixtureManipulatorTest.php new file mode 100644 index 00000000000..566201f9135 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/FixtureManipulatorTest.php @@ -0,0 +1,280 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\fixture_manipulator\FixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\Tests\package_manager\Traits\InstalledPackagesListTrait; +use Drupal\package_manager\PathLocator; + +/** + * @coversDefaultClass \Drupal\fixture_manipulator\FixtureManipulator + * + * @group package_manager + */ +class FixtureManipulatorTest extends PackageManagerKernelTestBase { + + use InstalledPackagesListTrait; + + /** + * The root directory of the test project. + * + * @var string + */ + private string $dir; + + /** + * The exception expected in ::tearDown() of this test. + * + * @var \Exception + */ + private \Exception $expectedTearDownException; + + /** + * The Composer inspector service. + * + * @var \Drupal\package_manager\ComposerInspector + */ + private ComposerInspector $inspector; + + /** + * The original fixture package list at the start of the test. + * + * @var \Drupal\package_manager\InstalledPackagesList + */ + private InstalledPackagesList $originalFixturePackages; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + $this->inspector = $this->container->get(ComposerInspector::class); + + $manipulator = new ActiveFixtureManipulator(); + $manipulator + ->addPackage([ + 'name' => 'my/package', + 'type' => 'library', + 'version' => '1.2.3', + ]) + ->addPackage( + [ + 'name' => 'my/dev-package', + 'version' => '2.1.0', + 'type' => 'library', + ], + TRUE + ) + ->commitChanges(); + $this->originalFixturePackages = $this->inspector->getInstalledPackagesList($this->dir); + } + + /** + * @covers ::addPackage + */ + public function testAddPackage(): void { + // Packages cannot be added without a name. + foreach (['name', 'type'] as $require_key) { + // Make a package that is missing the required key. + $package = array_diff_key( + [ + 'name' => 'Any old name', + 'type' => 'Any old type', + ], + [$require_key => ''] + ); + try { + $manipulator = new ActiveFixtureManipulator(); + $manipulator->addPackage($package) + ->commitChanges(); + $this->fail("Adding a package without the '$require_key' should raise an error."); + } + catch (\UnexpectedValueException $e) { + $this->assertSame("The '$require_key' is required when calling ::addPackage().", $e->getMessage()); + } + } + + // We should get a helpful error if the name is not a valid package name. + try { + $manipulator = new ActiveFixtureManipulator(); + $manipulator->addPackage([ + 'name' => 'my_drupal_module', + 'type' => 'drupal-module', + ]) + ->commitChanges(); + $this->fail('Trying to add a package with an invalid name should raise an error.'); + } + catch (\UnexpectedValueException $e) { + $this->assertSame("'my_drupal_module' is not a valid package name.", $e->getMessage()); + } + + // We should not be able to add an existing package. + try { + $manipulator = new ActiveFixtureManipulator(); + $manipulator->addPackage([ + 'name' => 'my/package', + 'type' => 'library', + ]) + ->commitChanges(); + $this->fail('Trying to add an existing package should raise an error.'); + } + catch (\LogicException $e) { + $this->assertStringContainsString("Expected package 'my/package' to not be installed, but it was.", $e->getMessage()); + } + // Ensure that none of the failed calls to ::addPackage() changed the installed + // packages. + $this->assertPackageListsEqual($this->originalFixturePackages, $this->inspector->getInstalledPackagesList($this->dir)); + $root_info = $this->inspector->getRootPackageInfo($this->dir); + $this->assertSame( + ['drupal/core-dev', 'my/dev-package'], + array_keys($root_info['devRequires']) + ); + } + + /** + * @covers ::modifyPackageConfig + */ + public function testModifyPackageConfig(): void { + // Assert ::modifyPackage() works with a package in an existing fixture not + // created by ::addPackage(). + $decode_packages_json = function (): array { + return json_decode(file_get_contents($this->dir . "/packages.json"), TRUE, flags: JSON_THROW_ON_ERROR); + }; + $original_packages_json = $decode_packages_json(); + (new ActiveFixtureManipulator()) + // @see ::setUp() + ->modifyPackageConfig('my/dev-package', '2.1.0', ['description' => 'something else'], TRUE) + ->commitChanges(); + // Verify that the package is indeed properly installed. + $this->assertSame('2.1.0', $this->inspector->getInstalledPackagesList($this->dir)['my/dev-package']?->version); + // Verify that the original exists, but has no description. + $this->assertSame('my/dev-package', $original_packages_json['packages']['my/dev-package']['2.1.0']['name']); + $this->assertArrayNotHasKey('description', $original_packages_json); + // Verify that the description was updated. + $this->assertSame('something else', $decode_packages_json()['packages']['my/dev-package']['2.1.0']['description']); + + (new ActiveFixtureManipulator()) + // Add a key to an existing package. + ->modifyPackageConfig('my/package', '1.2.3', ['extra' => ['foo' => 'bar']]) + // Change a key in an existing package. + ->setVersion('my/dev-package', '3.2.1', TRUE) + ->commitChanges(); + $this->assertSame(['foo' => 'bar'], $decode_packages_json()['packages']['my/package']['1.2.3']['extra']); + $this->assertSame('3.2.1', $this->inspector->getInstalledPackagesList($this->dir)['my/dev-package']?->version); + } + + /** + * @covers ::removePackage + */ + public function testRemovePackage(): void { + // We should not be able to remove a package that's not installed. + try { + (new ActiveFixtureManipulator()) + ->removePackage('junk/drawer') + ->commitChanges(); + $this->fail('Removing a non-existent package should raise an error.'); + } + catch (\LogicException $e) { + $this->assertStringContainsString('junk/drawer is not required in your composer.json and has not been remove', $e->getMessage()); + } + + // Remove the 2 packages that were added in ::setUp(). + (new ActiveFixtureManipulator()) + ->removePackage('my/package') + ->removePackage('my/dev-package', TRUE) + ->commitChanges(); + $expected_packages = $this->originalFixturePackages->getArrayCopy(); + unset($expected_packages['my/package'], $expected_packages['my/dev-package']); + $expected_list = new InstalledPackagesList($expected_packages); + $this->assertPackageListsEqual($expected_list, $this->inspector->getInstalledPackagesList($this->dir)); + $root_info = $this->inspector->getRootPackageInfo($this->dir); + $this->assertSame( + ['drupal/core-dev'], + array_keys($root_info['devRequires']) + ); + } + + /** + * Test that an exception is thrown if ::commitChanges() is not called. + */ + public function testActiveManipulatorNoCommitError(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('commitChanges() must be called.'); + (new ActiveFixtureManipulator()) + ->setVersion('drupal/core', '1.2.3'); + } + + /** + * @covers ::addDotGitFolder + */ + public function testAddDotGitFolder(): void { + $path_locator = $this->container->get(PathLocator::class); + $project_root = $path_locator->getProjectRoot(); + $this->assertFalse(is_dir($project_root . "/relative/path/.git")); + // We should not be able to add a git folder to a non-existing directory. + try { + (new FixtureManipulator()) + ->addDotGitFolder($project_root . "/relative/path") + ->commitChanges($project_root); + $this->fail('Trying to create a .git directory that already exists should raise an error.'); + } + catch (\LogicException $e) { + $this->assertSame('No directory exists at ' . $project_root . '/relative/path.', $e->getMessage()); + } + mkdir($project_root . "/relative/path", 0777, TRUE); + $fixture_manipulator = (new FixtureManipulator()) + ->addPackage([ + 'name' => 'relative/project_path', + 'type' => 'drupal-module', + ]) + ->addDotGitFolder($path_locator->getVendorDirectory() . "/relative/project_path") + ->addDotGitFolder($project_root . "/relative/path"); + $this->assertTrue(!is_dir($project_root . "/relative/project_path/.git")); + $fixture_manipulator->commitChanges($project_root); + $this->assertTrue(is_dir($project_root . "/relative/path/.git")); + // We should not be able to create already existing directory. + try { + (new FixtureManipulator()) + ->addDotGitFolder($project_root . "/relative/path") + ->commitChanges($project_root); + $this->fail('Trying to create a .git directory that already exists should raise an error.'); + } + catch (\LogicException $e) { + $this->assertStringContainsString("A .git directory already exists at " . $project_root, $e->getMessage()); + } + } + + /** + * Tests that the stage manipulator throws an exception if not committed. + */ + public function testStagedFixtureNotCommitted(): void { + $this->expectedTearDownException = new \LogicException('The StageFixtureManipulator has arguments that were not cleared. This likely means that the PostCreateEvent was never fired.'); + $this->getStageFixtureManipulator()->setVersion('any-org/any-package', '3.2.1'); + } + + /** + * {@inheritdoc} + * + * @todo Remove the line below when https://github.com/phpstan/phpstan-phpunit/issues/187 is fixed. + * @phpstan-ignore-next-line + */ + protected function tearDown(): void { + try { + parent::tearDown(); + } + catch (\Exception $exception) { + if (!(get_class($exception) === get_class($this->expectedTearDownException) && $exception->getMessage() === $this->expectedTearDownException->getMessage())) { + throw $exception; + } + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php b/core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php new file mode 100644 index 00000000000..d0fb6b37827 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/InstalledPackagesListTest.php @@ -0,0 +1,170 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Component\Serialization\Yaml; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\package_manager\PathLocator; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackagesList + * + * @group package_manager + */ +class InstalledPackagesListTest extends PackageManagerKernelTestBase { + + /** + * @covers \Drupal\package_manager\InstalledPackage::getProjectName + * @covers ::getPackageByDrupalProjectName + */ + public function testPackageByDrupalProjectName(): void { + // In getPackageByDrupalProjectName(), we don't expect that projects will be + // in the "correct" places -- for example, we don't assume that modules will + // be in the `modules` directory, or themes will be the `themes` directory. + // So, in this test, we ensure that flexibility works by just throwing all + // the projects into a single `projects` directory. + $projects_path = $this->container->get(PathLocator::class) + ->getProjectRoot() . '/projects'; + + // The project name does not match the package name, and the project + // physically exists. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/theme_project') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/a_package' => InstalledPackage::createFromArray([ + 'name' => 'drupal/a_package', + 'version' => '1.0.0', + 'type' => 'drupal-theme', + 'path' => $projects_path . '/theme_project', + ]), + ]); + $this->assertSame($list['drupal/a_package'], $list->getPackageByDrupalProjectName('theme_project')); + + // The project physically exists, but the package path points to the wrong + // place. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/example3') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/example3' => InstalledPackage::createFromArray([ + 'name' => 'drupal/example3', + 'version' => '1.0.0', + 'type' => 'drupal-module', + // This path exists, but it doesn't contain the `example3` project. + 'path' => $projects_path . '/theme_project', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('example3')); + + // The project does not physically exist, which means it must be a metapackage. + $list = new InstalledPackagesList([ + 'drupal/missing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/missing', + 'version' => '1.0.0', + 'type' => 'metapackage', + 'path' => NULL, + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('missing')); + + // The project physically exists in a subdirectory of the package. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/grab_bag/modules/module_in_subdirectory') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/grab_bag' => InstalledPackage::createFromArray([ + 'name' => 'drupal/grab_bag', + 'version' => '1.0.0', + 'type' => 'drupal-profile', + 'path' => $projects_path . '/grab_bag', + ]), + ]); + $this->assertSame($list['drupal/grab_bag'], $list->getPackageByDrupalProjectName('module_in_subdirectory')); + + // The package name matches a project that physically exists, but the + // package vendor is not `drupal`. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/not_from_drupal') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'vendor/not_from_drupal' => InstalledPackage::createFromArray([ + 'name' => 'vendor/not_from_drupal', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/not_from_drupal', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('not_from_drupal')); + + // These package names match physically existing projects, and they are + // from the `drupal` vendor, but they're not supported package types. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/custom_module') + ->addProjectAtPath('projects/custom_theme') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/custom_module' => InstalledPackage::createFromArray([ + 'name' => 'drupal/custom_module', + 'version' => '1.0.0', + 'type' => 'drupal-custom-module', + 'path' => $projects_path . '/custom_module', + ]), + 'drupal/custom_theme' => InstalledPackage::createFromArray([ + 'name' => 'drupal/custom_theme', + 'version' => '1.0.0', + 'type' => 'drupal-custom-theme', + 'path' => $projects_path . '/custom_theme', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('custom_module')); + $this->assertNull($list->getPackageByDrupalProjectName('custom_theme')); + + // The `project` key has been removed from the info file. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/no_project_key') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/no_project_key' => InstalledPackage::createFromArray([ + 'name' => 'drupal/no_project_key', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/no_project_key', + ]), + ]); + $info_file = $list['drupal/no_project_key']->path . '/no_project_key.info.yml'; + $this->assertFileIsWritable($info_file); + $info = Yaml::decode(file_get_contents($info_file)); + unset($info['project']); + file_put_contents($info_file, Yaml::encode($info)); + $this->assertNull($list->getPackageByDrupalProjectName('no_project_key')); + + // The project name is repeated. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/duplicate_project') + ->addProjectAtPath('projects/repeat/duplicate_project') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/test_project1' => InstalledPackage::createFromArray([ + 'name' => 'drupal/test_project1', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/duplicate_project', + ]), + 'drupal/test_project2' => InstalledPackage::createFromArray([ + 'name' => 'drupal/test_project2', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/repeat/duplicate_project', + ]), + ]); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage("Project 'duplicate_project' was found in packages 'drupal/test_project1' and 'drupal/test_project2'."); + $list->getPackageByDrupalProjectName('duplicate_project'); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php new file mode 100644 index 00000000000..bcd32f639df --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\Validator\LockFileValidator; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_bypass\NoOpStager; +use Prophecy\Argument; + +/** + * @coversDefaultClass \Drupal\package_manager\Validator\LockFileValidator + * @group package_manager + * @internal + */ +class LockFileValidatorTest extends PackageManagerKernelTestBase { + + /** + * The path of the active directory in the test project. + * + * @var string + */ + private $activeDir; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->activeDir = $this->container->get(PathLocator::class) + ->getProjectRoot(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + // Temporarily mock the Composer inspector to prevent it from complaining + // over the lack of a lock file if it's invoked by other validators. + $inspector = $this->prophesize(ComposerInspector::class); + $arguments = Argument::cetera(); + $inspector->getConfig('allow-plugins', $arguments)->willReturn('[]'); + $inspector->getConfig('secure-http', $arguments)->willReturn('true'); + $inspector->getConfig('disable-tls', $arguments)->willReturn('false'); + $inspector->getConfig('extra', $arguments)->willReturn('{}'); + $inspector->getConfig('minimum-stability', $arguments)->willReturn('stable'); + $inspector->getInstalledPackagesList($arguments)->willReturn(new InstalledPackagesList()); + $inspector->getAllowPluginsConfig($arguments)->willReturn([]); + $inspector->validate($arguments); + $inspector->getRootPackageInfo($arguments)->willReturn([]); + $container->set(ComposerInspector::class, $inspector->reveal()); + } + + /** + * Tests that if no active lock file exists, a stage cannot be created. + * + * @covers ::storeHash + */ + public function testCreateWithNoLock(): void { + unlink($this->activeDir . '/composer.lock'); + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + $lock_file_path = $project_root . DIRECTORY_SEPARATOR . 'composer.lock'; + $no_lock = ValidationResult::createError([ + t('The active lock file (@file) does not exist.', ['@file' => $lock_file_path]), + ]); + $stage = $this->assertResults([$no_lock], PreCreateEvent::class); + // The stage was not created successfully, so the status check should be + // clear. + $this->assertStatusCheckResults([], $stage); + } + + /** + * Tests that if an active lock file exists, a stage can be created. + * + * @covers ::storeHash + * @covers ::deleteHash + */ + public function testCreateWithLock(): void { + $this->assertResults([]); + + // Change the lock file to ensure the stored hash of the previous version + // has been deleted. + file_put_contents($this->activeDir . '/composer.lock', '{"changed": true}'); + $this->assertResults([]); + } + + /** + * Tests validation when the lock file has changed. + * + * @dataProvider providerValidateStageEvents + */ + public function testLockFileChanged(string $event_class): void { + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener changes lock file before the validator + // runs. + $this->addEventTestListener(function () { + $lock = json_decode(file_get_contents($this->activeDir . '/composer.lock'), TRUE, flags: JSON_THROW_ON_ERROR); + $lock['extra']['key'] = 'value'; + file_put_contents($this->activeDir . '/composer.lock', json_encode($lock, JSON_THROW_ON_ERROR)); + }, $event_class); + $result = ValidationResult::createError([ + t('Unexpected changes were detected in the active lock file (@file), which indicates that other Composer operations were performed since this Package Manager operation started. This can put the code base into an unreliable state and therefore is not allowed.', + ['@file' => $this->activeDir . '/composer.lock']), + ], t('Problem detected in lock file during stage operations.')); + $stage = $this->assertResults([$result], $event_class); + // A status check should agree that there is an error here. + $this->assertStatusCheckResults([$result], $stage); + } + + /** + * Tests validation when the lock file is deleted. + * + * @dataProvider providerValidateStageEvents + */ + public function testLockFileDeleted(string $event_class): void { + // Add a listener with an extremely high priority to the same event that + // should raise the validation error. Because the validator uses the default + // priority of 0, this listener deletes lock file before the validator + // runs. + $this->addEventTestListener(function () { + unlink($this->activeDir . '/composer.lock'); + }, $event_class); + $result = ValidationResult::createError([ + t('The active lock file (@file) does not exist.', [ + '@file' => $this->activeDir . '/composer.lock', + ]), + ], t('Problem detected in lock file during stage operations.')); + $stage = $this->assertResults([$result], $event_class); + // A status check should agree that there is an error here. + $this->assertStatusCheckResults([$result], $stage); + } + + /** + * Tests exception when a stored hash of the active lock file is unavailable. + * + * @dataProvider providerValidateStageEvents + */ + public function testNoStoredHash(string $event_class): void { + $reflector = new \ReflectionClassConstant(LockFileValidator::class, 'KEY'); + $key = $reflector->getValue(); + + // Add a listener with an extremely high priority to the same event that + // should throw an exception. Because the validator uses the default + // priority of 0, this listener deletes stored hash before the validator + // runs. + $this->addEventTestListener(function () use ($key) { + $this->container->get('keyvalue') + ->get('package_manager') + ->delete($key); + }, $event_class); + + $stage = $this->createStage(); + $stage->create(); + try { + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + } + catch (StageException $e) { + $this->assertSame(\LogicException::class, $e->getPrevious()::class); + $this->assertSame('Stored hash key deleted.', $e->getMessage()); + } + } + + /** + * Tests validation when the staged and active lock files are identical. + */ + public function testApplyWithNoChange(): void { + // Leave the staged lock file alone. + NoOpStager::setLockFileShouldChange(FALSE); + + $result = ValidationResult::createError([ + t('There appear to be no pending Composer operations because the active lock file (<PROJECT_ROOT>/composer.lock) and the staged lock file (<STAGE_DIR>/composer.lock) are identical.'), + ], t('Problem detected in lock file during stage operations.')); + $stage = $this->assertResults([$result], PreApplyEvent::class); + // A status check shouldn't produce raise any errors, because it's only + // during pre-apply that we care if there are any pending Composer + // operations. + $this->assertStatusCheckResults([], $stage); + } + + /** + * Tests StatusCheckEvent when the stage is available. + */ + public function testStatusCheckAvailableStage():void { + $this->assertStatusCheckResults([]); + } + + /** + * Data provider for test methods that validate the stage directory. + * + * @return string[][] + * The test cases. + */ + public static function providerValidateStageEvents(): array { + return [ + 'pre-require' => [ + PreRequireEvent::class, + ], + 'pre-apply' => [ + PreApplyEvent::class, + ], + ]; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php new file mode 100644 index 00000000000..24084014126 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\MultisiteValidator + * @group package_manager + * @internal + */ +class MultisiteValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testMultisite(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerMultisite(): array { + return [ + 'sites.php present and listing multiple sites' => [ + <<<'PHP' +<?php +// Site 1: the main site. +$sites['example.com'] = 'default'; +// Site 2: the shop. +$sites['shop.example.com'] = 'shop'; +PHP, + [ + ValidationResult::createError([ + t('Drupal multisite is not supported by Package Manager.'), + ]), + ], + ], + 'sites.php present and listing single site' => [ + <<<'PHP' +<?php +// Site 1: the main site. +$sites['example.com'] = 'default'; +PHP, + [], + ], + 'sites.php present and listing multiple aliases for a single site' => [ + <<<'PHP' +<?php +// Site 1: the main site. +$sites['example.com'] = 'example'; +// Alias for site 1! +$sites['example.dev'] = 'example'; +PHP, + [], + ], + 'sites.php absent' => [ + NULL, + [], + ], + ]; + } + + /** + * Tests that Package Manager flags an error if run in a multisite. + * + * @param string|null $sites_php + * The sites.php contents to write, if any. If NULL, no sites.php will be + * created. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerMultisite + */ + public function testMultisite(?string $sites_php, array $expected_results = []): void { + if ($sites_php) { + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + file_put_contents($project_root . '/sites/sites.php', $sites_php); + } + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests that an error is flagged if run in a multisite during pre-apply. + * + * @param string|null $sites_php + * The sites.php contents to write, if any. If NULL, no sites.php will be + * created. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerMultisite + */ + public function testMultisiteDuringPreApply(?string $sites_php, array $expected_results = []): void { + $this->addEventTestListener(function () use ($sites_php): void { + if ($sites_php) { + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + file_put_contents($project_root . '/sites/sites.php', $sites_php); + } + }); + $this->assertResults($expected_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php new file mode 100644 index 00000000000..93360a5c500 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php @@ -0,0 +1,159 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\SupportedReleaseValidator; +use Drupal\Tests\package_manager\Traits\ComposerInstallersTrait; + +/** + * @covers \Drupal\package_manager\Validator\OverwriteExistingPackagesValidator + * @group package_manager + * @internal + */ +class OverwriteExistingPackagesValidatorTest extends PackageManagerKernelTestBase { + + use ComposerInstallersTrait; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // In this test, we don't care whether the updated projects are secure and + // supported. + $this->disableValidators[] = SupportedReleaseValidator::class; + parent::setUp(); + + $this->installComposerInstallers($this->container->get(PathLocator::class)->getProjectRoot()); + } + + /** + * Tests that new installed packages overwrite existing directories. + * + * The fixture simulates a scenario where the active directory has four + * modules installed: module_1, module_2, module_5 and module_6. None of them + * are managed by Composer. These modules will be moved into the stage + * directory by the 'package_manager_bypass' module. + */ + public function testNewPackagesOverwriteExisting(): void { + (new ActiveFixtureManipulator()) + ->addProjectAtPath('modules/module_1') + ->addProjectAtPath('modules/module_2') + ->addProjectAtPath('modules/module_5') + ->addProjectAtPath('modules/module_6') + ->commitChanges(); + $stage_manipulator = $this->getStageFixtureManipulator(); + + $installer_paths = []; + // module_1 and module_2 will raise errors because they would overwrite + // non-Composer managed paths in the active directory. + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/other_module_1', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE + ); + $installer_paths['modules/module_1'] = ['drupal/other_module_1']; + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/other_module_2', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE, + ); + $installer_paths['modules/module_2'] = ['drupal/other_module_2']; + + // module_3 will cause no problems, since it doesn't exist in the active + // directory at all. + $stage_manipulator->addPackage([ + 'name' => 'drupal/other_module_3', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE, + ); + $installer_paths['modules/module_3'] = ['drupal/other_module_3']; + + // module_4 doesn't exist in the active directory but the 'install_path' as + // known to Composer in the staged directory collides with module_6 in the + // active directory which will cause an error. + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/module_4', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE + ); + $installer_paths['modules/module_6'] = ['drupal/module_4']; + + // module_5_different_path will not cause a problem, even though its package + // name is drupal/module_5, because its project name and path in the stage + // directory differ from the active directory. + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/other_module_5', + 'version' => '1.3.0', + 'type' => 'drupal-module', + ], + FALSE, + TRUE + ); + $installer_paths['modules/module_5_different_path'] = ['drupal/other_module_5']; + + // Set the installer path config in the active directory this will be + // copied to the stage directory where we install the packages. + $this->setInstallerPaths($installer_paths, $this->container->get(PathLocator::class)->getProjectRoot()); + + // Add a package without an install_path set which will not raise an error. + // The most common example of this in the Drupal ecosystem is a submodule. + $stage_manipulator->addPackage( + [ + 'name' => 'drupal/sub-module', + 'version' => '1.3.0', + 'type' => 'metapackage', + ], + FALSE, + TRUE + ); + $inspector = $this->container->get(ComposerInspector::class); + $listener = function (PostCreateEvent $event) use ($inspector) { + $list = $inspector->getInstalledPackagesList($event->stage->getStageDirectory()); + $this->assertArrayHasKey('drupal/sub-module', $list->getArrayCopy()); + $this->assertArrayHasKey('drupal/other_module_1', $list->getArrayCopy()); + // Confirm that metapackage will have a NULL install path. + $this->assertNull($list['drupal/sub-module']->path); + // Confirm another package has specified install path. + $this->assertSame($list['drupal/other_module_1']->path, $event->stage->getStageDirectory() . '/modules/module_1'); + }; + $this->addEventTestListener($listener, PostCreateEvent::class); + + $expected_results = [ + ValidationResult::createError([ + t('The new package drupal/module_4 will be installed in the directory /modules/module_6, which already exists but is not managed by Composer.'), + ]), + ValidationResult::createError([ + t('The new package drupal/other_module_1 will be installed in the directory /modules/module_1, which already exists but is not managed by Composer.'), + ]), + ValidationResult::createError([ + t('The new package drupal/other_module_2 will be installed in the directory /modules/module_2, which already exists but is not managed by Composer.'), + ]), + ]; + $this->assertResults($expected_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php new file mode 100644 index 00000000000..ed7b5a7f377 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -0,0 +1,520 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Component\FileSystem\FileSystem as DrupalFileSystem; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\Core\Queue\QueueFactory; +use Drupal\Core\Site\Settings; +use Drupal\fixture_manipulator\StageFixtureManipulator; +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\Event\PreApplyEvent; +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; +use Drupal\package_manager\StageBase; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use Drupal\Tests\package_manager\Traits\ComposerStagerTestTrait; +use Drupal\Tests\package_manager\Traits\FixtureManipulatorTrait; +use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; +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\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Psr\Http\Message\RequestInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Base class for kernel tests of Package Manager's functionality. + * + * @internal + */ +abstract class PackageManagerKernelTestBase extends KernelTestBase { + + use AssertPreconditionsTrait; + use ComposerStagerTestTrait; + use FixtureManipulatorTrait; + use FixtureUtilityTrait; + use StatusCheckTrait; + 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; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'fixture_manipulator', + 'package_manager', + 'package_manager_bypass', + 'system', + 'update', + 'update_test', + ]; + + /** + * The service IDs of any validators to disable. + * + * @var string[] + */ + protected $disableValidators = []; + + /** + * 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 Filesystem $fileSystem; + + /** + * A logger that will fail the test if Package Manager logs any errors. + * + * @var \ColinODell\PsrTestLogger\TestLogger + * + * @see ::tearDown() + */ + protected 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 + // 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); + } + + /** + * {@inheritdoc} + */ + protected function enableModules(array $modules): void { + parent::enableModules($modules); + $this->registerPostUpdateFunctions(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + 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(DiskSpaceValidator::class)->addTag('persist'); + + // Ensure that our failure logger will survive container rebuilds. + $container->getDefinition('logger.channel.package_manager') + ->addTag('persist'); + + array_walk($this->disableValidators, $container->removeDefinition(...)); + } + + /** + * Creates a stage object for testing purposes. + * + * @return \Drupal\Tests\package_manager\Kernel\TestStage + * A stage object, with test-only modifications. + */ + protected function createStage(): TestStage { + return new TestStage( + $this->container->get(PathLocator::class), + $this->container->get(BeginnerInterface::class), + $this->container->get(StagerInterface::class), + $this->container->get(CommitterInterface::class), + $this->container->get(QueueFactory::class), + $this->container->get('event_dispatcher'), + $this->container->get('tempstore.shared'), + $this->container->get('datetime.time'), + $this->container->get(PathFactoryInterface::class), + $this->container->get(FailureMarker::class) + ); + } + + /** + * 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\StageBase + * The stage that was used to collect the validation results. + */ + protected function assertResults(array $expected_results, ?string $event_class = NULL): StageBase { + $stage = $this->createStage(); + + try { + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + $stage->postApply(); + $stage->destroy(); + + // If we did not get an exception, ensure we didn't expect any results. + $this->assertValidationResultsEqual([], $expected_results); + } + catch (StageEventException $e) { + $this->assertNotEmpty($expected_results); + $this->assertInstanceOf($event_class, $e->event); + $this->assertExpectedResultsFromException($expected_results, $e); + } + return $stage; + } + + /** + * 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. + */ + 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); + } + + /** + * 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 { + static $updates = []; + $updates = array_merge($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); + + // 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); + + // This validator will persist through container rebuilds. + // @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(DiskSpaceValidator::class, $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. + * + * @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); + } + + /** + * 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); + } + + /** + * {@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); + } + + 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.'); + parent::tearDown(); + } + + /** + * 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); + + $stage = $event->stage; + $stage_dir = $stage->stageDirectoryExists() ? $stage->getStageDirectory() : NULL; + $this->assertValidationResultsEqual($expected_results, $event->getResults(), NULL, $stage_dir); + } + +} + +/** + * Defines a stage specifically for testing purposes. + */ +class TestStage extends StageBase { + + /** + * {@inheritdoc} + */ + protected string $type = 'package_manager:test'; + + /** + * Implements the magic __sleep() method. + * + * 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/auto_updates/issues/3312619#comment-14801308 + */ + public function __sleep(): array { + return []; + } + +} + +/** + * 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'; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php new file mode 100644 index 00000000000..b1863bf7b21 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php @@ -0,0 +1,140 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\Core\Serialization\Yaml; +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; +use Drupal\Tests\package_manager\Traits\ComposerInstallersTrait; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @covers \Drupal\package_manager\PathExcluder\GitExcluder + * @group package_manager + * @internal + */ +class GitExcluderTest extends PackageManagerKernelTestBase { + + use ComposerInstallersTrait; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + $this->installComposerInstallers($project_root); + $active_manipulator = new ActiveFixtureManipulator(); + $active_manipulator + ->addPackage([ + 'name' => 'foo/package_known_to_composer_removed_later', + 'type' => 'drupal-module', + 'version' => '1.0.0', + ], FALSE, TRUE) + ->addPackage([ + 'name' => 'foo/custom_package_known_to_composer', + 'type' => 'drupal-custom-module', + 'version' => '1.0.0', + ], FALSE, TRUE) + ->addPackage([ + 'name' => 'foo/package_with_different_installer_path_known_to_composer', + 'type' => 'drupal-module', + 'version' => '1.0.0', + ], FALSE, TRUE); + // Set the installer path config in the project root where we install the + // package. + $installer_paths['different_installer_path/package_known_to_composer'] = ['foo/package_with_different_installer_path_known_to_composer']; + $this->setInstallerPaths($installer_paths, $project_root); + $active_manipulator->addProjectAtPath("modules/module_not_known_to_composer_in_active") + ->addDotGitFolder($project_root . "/modules/module_not_known_to_composer_in_active") + ->addDotGitFolder($project_root . "/modules/contrib/package_known_to_composer_removed_later") + ->addDotGitFolder($project_root . "/modules/custom/custom_package_known_to_composer") + ->addDotGitFolder($project_root . "/different_installer_path/package_known_to_composer") + ->commitChanges(); + } + + /** + * Tests that Git directories are excluded from stage during PreCreate. + */ + public function testGitDirectoriesExcludedActive(): void { + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $stage = $this->createStage(); + $stage->create(); + /** @var \Drupal\package_manager_bypass\LoggingBeginner $beginner */ + $beginner = $this->container->get(BeginnerInterface::class); + $beginner_args = $beginner->getInvocationArguments(); + $excluded_paths = [ + '.git', + 'modules/module_not_known_to_composer_in_active/.git', + 'modules/example/.git', + ]; + foreach ($excluded_paths as $excluded_path) { + $this->assertContains($excluded_path, $beginner_args[0][2]); + } + $not_excluded_paths = [ + 'modules/contrib/package_known_to_composer_removed_later/.git', + 'modules/custom/custom_package_known_to_composer/.git', + 'different_installer_path/package_known_to_composer/.git', + ]; + foreach ($not_excluded_paths as $not_excluded_path) { + $this->assertNotContains($not_excluded_path, $beginner_args[0][2]); + } + } + + /** + * Tests that Git directories are excluded from active during PreApply. + */ + public function testGitDirectoriesExcludedStage(): void { + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $this->getStageFixtureManipulator() + ->removePackage('foo/package_known_to_composer_removed_later'); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + // Adding a module with .git in stage which is unknown to composer, we + // expect it to not be copied to the active directory. + $path = "$stage_dir/modules/unknown_to_composer_in_stage"; + $fs = new Filesystem(); + $fs->mkdir("$path/.git"); + file_put_contents( + "$path/unknown_to_composer.info.yml", + Yaml::encode([ + 'name' => 'Unknown to composer in stage', + 'type' => 'module', + 'core_version_requirement' => '^9.7 || ^10', + ]) + ); + file_put_contents("$path/.git/excluded.txt", 'Phoenix!'); + + $stage->apply(); + /** @var \Drupal\package_manager_bypass\LoggingCommitter $committer */ + $committer = $this->container->get(CommitterInterface::class); + $committer_args = $committer->getInvocationArguments(); + $excluded_paths = [ + '.git', + 'modules/module_not_known_to_composer_in_active/.git', + 'modules/example/.git', + ]; + // We are missing "modules/unknown_to_composer_in_stage/.git" in excluded + // paths because there is no validation for it as it is assumed about any + // new .git folder in stage directory that either composer is aware of it or + // the developer knows what they are doing. + foreach ($excluded_paths as $excluded_path) { + $this->assertContains($excluded_path, $committer_args[0][2]); + } + $this->assertNotContains('modules/unknown_to_composer_in_stage/.git', $committer_args[0][2]); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/NodeModulesExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/NodeModulesExcluderTest.php new file mode 100644 index 00000000000..6bf3b58ff9b --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/NodeModulesExcluderTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\NodeModulesExcluder + * @group package_manager + * @internal + */ +class NodeModulesExcluderTest extends PackageManagerKernelTestBase { + + /** + * Tests that node_modules directories are excluded from stage operations. + */ + public function testExcludedPaths(): void { + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $excluded = [ + "core/node_modules/exclude.txt", + 'modules/example/node_modules/exclude.txt', + ]; + foreach ($excluded as $path) { + mkdir(dirname("$active_dir/$path"), 0777, TRUE); + file_put_contents("$active_dir/$path", "This file should never be staged."); + } + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php new file mode 100644 index 00000000000..cb347e55c12 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\PathExcluder\SiteConfigurationExcluder; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\SiteConfigurationExcluder + * @group package_manager + * @internal + */ +class SiteConfigurationExcluderTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + $container->getDefinition(SiteConfigurationExcluder::class) + ->setClass(TestSiteConfigurationExcluder::class); + } + + /** + * Tests that certain paths are excluded from stage operations. + */ + public function testExcludedPaths(): void { + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + $site_path = 'sites/example.com'; + + // Update the event subscribers' dependencies. + $site_configuration_excluder = $this->container->get(SiteConfigurationExcluder::class); + $site_configuration_excluder->sitePath = $site_path; + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + $excluded = [ + "$site_path/settings.php", + "$site_path/settings.local.php", + "$site_path/services.yml", + // Default site-specific settings files should be excluded. + 'sites/default/settings.php', + 'sites/default/settings.local.php', + 'sites/default/services.yml', + ]; + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + // A non-excluded file in the default site directory should be staged. + $this->assertFileExists("$stage_dir/sites/default/stage.txt"); + // Regular module files should be staged. + $this->assertFileExists("$stage_dir/modules/example/example.info.yml"); + + // A new file added to the site directory in the stage directory should be + // copied to the active directory. + $file = "$stage_dir/sites/default/new.txt"; + touch($file); + $stage->apply(); + $this->assertFileExists("$active_dir/sites/default/new.txt"); + + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + + /** + * Tests that `sites/default` is made writable in the stage directory. + */ + public function testDefaultSiteDirectoryPermissions(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $live_dir = $project_root . '/sites/default'; + chmod($live_dir, 0555); + $this->assertDirectoryIsNotWritable($live_dir); + // Record the permissions of the directory now, so we can be sure those + // permissions are restored after apply. + $original_permissions = fileperms($live_dir); + $this->assertIsInt($original_permissions); + + $stage = $this->createStage(); + $stage->create(); + // The staged `sites/default` will be made world-writable, because we want + // to ensure the scaffold plugin can copy certain files into there. + $staged_dir = str_replace($project_root, $stage->getStageDirectory(), $live_dir); + $this->assertDirectoryIsWritable($staged_dir); + + $stage->require(['ext-json:*']); + $stage->apply(); + // After applying, the live directory should NOT inherit the staged + // directory's world-writable permissions. + $this->assertSame($original_permissions, fileperms($live_dir)); + } + +} + +/** + * A test version of the site configuration excluder, to expose internals. + */ +class TestSiteConfigurationExcluder extends SiteConfigurationExcluder { + + public string $sitePath; + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php new file mode 100644 index 00000000000..99fa0e2326a --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php @@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\SiteFilesExcluder + * @group package_manager + * @internal + */ +class SiteFilesExcluderTest extends PackageManagerKernelTestBase { + + /** + * Tests that public and private files are excluded from stage operations. + */ + public function testSiteFilesExcluded(): void { + // The private stream wrapper is only registered if this setting is set. + // @see \Drupal\Core\CoreServiceProvider::register() + $this->setSetting('file_private_path', 'private'); + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. This will also rebuild + // the container, enabling the private stream wrapper. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + // Ensure that we are using directories within the fake site fixture for + // public and private files. + $this->setSetting('file_public_path', "sites/example.com/files"); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + $excluded = [ + "sites/example.com/files/exclude.txt", + 'private/exclude.txt', + ]; + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + + /** + * Tests that invalid file settings do not cause errors. + */ + public function testInvalidFileSettings(): void { + $invalid_path = '/path/does/not/exist'; + $this->assertFileDoesNotExist($invalid_path); + $this->setSetting('file_public_path', $invalid_path); + $this->setSetting('file_private_path', $invalid_path); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + $this->assertStatusCheckResults([]); + $this->assertResults([]); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php new file mode 100644 index 00000000000..2f8cb42901e --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php @@ -0,0 +1,152 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\Core\Database\Connection; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; +use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @covers \Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder + * @group package_manager + * @internal + */ +class SqliteDatabaseExcluderTest extends PackageManagerKernelTestBase { + + /** + * The mocked database connection. + * + * @var \Drupal\Core\Database\Connection|\Prophecy\Prophecy\ObjectProphecy + */ + private Connection|ObjectProphecy $mockDatabase; + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + $this->mockDatabase = $this->prophesize(Connection::class); + $this->mockDatabase->driver() + ->willReturn('sqlite') + ->shouldBeCalled(); + $container->set('mock_database', $this->mockDatabase->reveal()); + + $container->getDefinition(SqliteDatabaseExcluder::class) + ->setArgument('$database', new Reference('mock_database')); + } + + /** + * Data provider for ::testSqliteDatabaseFilesExcluded(). + * + * @return array[] + * The test cases. + */ + public static function providerSqliteDatabaseFilesExcluded(): array { + return [ + // If the database is at a relative path, it should be excluded relative + // to the web root. + 'relative path in relocated web root' => [ + 'www', + 'db.sqlite', + 'www/db.sqlite', + ], + 'relative path, web root is project root' => [ + '', + 'db.sqlite', + 'db.sqlite', + ], + // If the database is at an absolute path in the project root, it should + // be excluded relative to the project root. + 'absolute path in relocated web root' => [ + 'www', + '<PROJECT_ROOT>/www/db.sqlite', + 'www/db.sqlite', + ], + 'absolute path, web root is project root' => [ + '', + '<PROJECT_ROOT>/db.sqlite', + 'db.sqlite', + ], + // If the database is outside the project root, the excluder doesn't need + // to do anything. + 'absolute path outside of project, relocated web root' => [ + 'www', + '/path/to/database.sqlite', + FALSE, + ], + 'absolute path outside of project, web root is project root' => [ + '', + '/path/to/database.sqlite', + FALSE, + ], + ]; + } + + /** + * Tests that SQLite database files are excluded from stage operations. + * + * @param string $web_root + * The web root that should be returned by the path locator. See + * \Drupal\package_manager\PathLocator::getWebRoot(). + * @param string $db_path + * The path of the SQLite database, as it should be reported by the database + * connection. This can be a relative or absolute path; it does not need to + * actually exist. + * @param string|false $expected_excluded_path + * The path to the database, as it should be given to + * CollectPathsToExcludeEvent. If FALSE, the database is located outside the + * project and therefore is not excluded. + * + * @dataProvider providerSqliteDatabaseFilesExcluded + */ + public function testSqliteDatabaseFilesExcluded(string $web_root, string $db_path, string|false $expected_excluded_path): void { + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + $project_root = $path_locator->getProjectRoot(); + + // Set the mocked web root, keeping everything else as-is. + $path_locator->setPaths( + $project_root, + $path_locator->getVendorDirectory(), + $web_root, + $path_locator->getStagingRoot(), + ); + $db_path = str_replace('<PROJECT_ROOT>', $project_root, $db_path); + $this->mockDatabase->getConnectionOptions() + ->willReturn(['database' => $db_path]) + ->shouldBeCalled(); + + $event = new CollectPathsToExcludeEvent( + $this->createStage(), + $path_locator, + $this->container->get(PathFactoryInterface::class), + ); + $actual_excluded_paths = $this->container->get('event_dispatcher') + ->dispatch($event) + ->getAll(); + + if (is_string($expected_excluded_path)) { + $expected_exclusions = [ + $expected_excluded_path, + $expected_excluded_path . '-shm', + $expected_excluded_path . '-wal', + ]; + $this->assertEmpty(array_diff($expected_exclusions, $actual_excluded_paths)); + } + else { + // The path of the database should not appear anywhere in the list of + // excluded paths. + $this->assertStringNotContainsString($db_path, serialize($actual_excluded_paths)); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php new file mode 100644 index 00000000000..93831d97d56 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\TestSiteExcluder + * @group package_manager + * @internal + */ +class TestSiteExcluderTest extends PackageManagerKernelTestBase { + + /** + * Tests that test site directories are excluded from stage operations. + */ + public function testTestSitesExcluded(): void { + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + $excluded = [ + 'sites/simpletest', + ]; + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/UnknownPathExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/UnknownPathExcluderTest.php new file mode 100644 index 00000000000..d68da1cba9a --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/UnknownPathExcluderTest.php @@ -0,0 +1,246 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\Component\FileSystem\FileSystem as DrupalFileSystem; +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; +use Symfony\Component\Filesystem\Filesystem; + +/** + * @covers \Drupal\package_manager\PathExcluder\UnknownPathExcluder + * @group package_manager + * @internal + */ +class UnknownPathExcluderTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function createTestProject(?string $source_dir = NULL): void { + // This class needs the test project to be varied for different test + // methods, so it cannot be called in the setup. + // @see ::createTestProjectForTemplate() + } + + /** + * Creates a test project with or without a nested webroot. + * + * @param bool $use_nested_webroot + * Whether to use a nested webroot. + */ + protected function createTestProjectForTemplate(bool $use_nested_webroot): void { + if (!$use_nested_webroot) { + // We are not using a nested webroot: the parent test project can be used. + parent::createTestProject(); + } + else { + // Create another directory and copy its contents from fake_site fixture. + $fake_site_with_nested_webroot = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . 'fake_site_with_nested_webroot'; + $fs = new Filesystem(); + if (is_dir($fake_site_with_nested_webroot)) { + $fs->remove($fake_site_with_nested_webroot); + } + $fs->mkdir($fake_site_with_nested_webroot); + $fs->mirror(__DIR__ . '/../../../fixtures/fake_site', $fake_site_with_nested_webroot); + + // Create a webroot directory in our new directory and copy all folders + // and files into it, except for ones that should always be in the + // project root. + $fs->mkdir($fake_site_with_nested_webroot . DIRECTORY_SEPARATOR . 'webroot'); + $paths_in_project_root = glob("$fake_site_with_nested_webroot/*"); + $keep_in_project_root = [ + $fake_site_with_nested_webroot . '/vendor', + $fake_site_with_nested_webroot . '/webroot', + $fake_site_with_nested_webroot . '/composer.json', + $fake_site_with_nested_webroot . '/composer.lock', + $fake_site_with_nested_webroot . '/custom', + ]; + foreach ($paths_in_project_root as $path_in_project_root) { + if (!in_array($path_in_project_root, $keep_in_project_root, TRUE)) { + $fs->rename($path_in_project_root, $fake_site_with_nested_webroot . '/webroot' . str_replace($fake_site_with_nested_webroot, '', $path_in_project_root)); + } + } + parent::createTestProject($fake_site_with_nested_webroot); + + // We need to reset the test paths with our new webroot. + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + + $path_locator->setPaths( + $path_locator->getProjectRoot(), + $path_locator->getVendorDirectory(), + 'webroot', + $path_locator->getStagingRoot() + ); + } + } + + /** + * Data provider for testUnknownPath(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerTestUnknownPath() { + return [ + 'unknown file where web and project root same' => [ + FALSE, + NULL, + ['unknown_file.txt'], + ], + 'unknown file where web and project root different' => [ + TRUE, + NULL, + ['unknown_file.txt'], + ], + 'unknown hidden file where web and project root same' => [ + FALSE, + NULL, + ['.unknown_file'], + ], + 'unknown hidden file where web and project root different' => [ + TRUE, + NULL, + ['.unknown_file'], + ], + 'unknown directory where web and project root same' => [ + FALSE, + 'unknown_dir', + ['unknown_dir/unknown_dir.README.md', 'unknown_dir/unknown_file.txt'], + ], + 'unknown directory where web and project root different' => [ + TRUE, + 'unknown_dir', + ['unknown_dir/unknown_dir.README.md', 'unknown_dir/unknown_file.txt'], + ], + 'unknown hidden directory where web and project root same' => [ + FALSE, + '.unknown_dir', + ['.unknown_dir/unknown_dir.README.md', '.unknown_dir/unknown_file.txt'], + ], + 'unknown hidden directory where web and project root different' => [ + TRUE, + '.unknown_dir', + ['.unknown_dir/unknown_dir.README.md', '.unknown_dir/unknown_file.txt'], + ], + ]; + } + + /** + * Tests that the unknown files and directories are excluded. + * + * @param bool $use_nested_webroot + * Whether to create test project with a nested webroot. + * @param string|null $unknown_dir + * The path of unknown directory to test or NULL none should be tested. + * @param string[] $unknown_files + * The list of unknown files. + * + * @dataProvider providerTestUnknownPath + */ + public function testUnknownPath(bool $use_nested_webroot, ?string $unknown_dir, array $unknown_files): void { + $this->createTestProjectForTemplate($use_nested_webroot); + + $active_dir = $this->container->get(PathLocator::class) + ->getProjectRoot(); + if ($unknown_dir) { + mkdir("$active_dir/$unknown_dir"); + } + foreach ($unknown_files as $unknown_file) { + file_put_contents("$active_dir/$unknown_file", "Unknown File"); + } + + $stage = $this->createStage(); + // Files are only excluded if the web root and project root are different. + // If anything in the project root is excluded, those paths should be + // logged. + if ($use_nested_webroot) { + $logger = new TestLogger(); + $this->container->get('logger.factory') + ->get('package_manager') + ->addLogger($logger); + + $this->runStatusCheck($stage); + $this->assertTrue($logger->hasRecordThatContains("The following paths in $active_dir aren't recognized as part of your Drupal site, so to be safe, Package Manager is excluding them from all stage operations. If these files are not needed for Composer to work properly in your site, no action is needed. Otherwise, you can disable this behavior by setting the <code>package_manager.settings:include_unknown_files_in_project_root</code> config setting to <code>TRUE</code>.", RfcLogLevel::INFO)); + foreach ($unknown_files as $unknown_file) { + // If $unknown_file is in a subdirectory, only the subdirectory is going + // to be logged as an excluded path. The excluder doesn't recurse into + // subdirectories. + if (str_contains($unknown_file, '/')) { + $unknown_file = dirname($unknown_file); + } + $this->assertTrue($logger->hasRecordThatContains($unknown_file, RfcLogLevel::INFO)); + } + } + + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + foreach ($unknown_files as $path) { + $this->assertFileExists("$active_dir/$path"); + if ($use_nested_webroot) { + // It will not exist in stage as it will be excluded because web and + // project root are different. + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + else { + // If the project root and web root are the same, unknown files will not + // be excluded, so this path should exist in the stage directory. + $this->assertFileExists("$stage_dir/$path"); + } + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($unknown_files as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + + /** + * Tests that the excluder can be disabled by a config flag. + */ + public function testExcluderCanBeDisabled(): void { + $this->createTestProjectForTemplate(TRUE); + + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + mkdir($project_root . '/unknown'); + touch($project_root . '/unknown/file.txt'); + + $config = $this->config('package_manager.settings'); + $config->set('include_unknown_files_in_project_root', TRUE)->save(); + + $stage = $this->createStage(); + $stage->create(); + $this->assertFileExists($stage->getStageDirectory() . '/unknown/file.txt'); + $stage->destroy(); + + $config->set('include_unknown_files_in_project_root', FALSE)->save(); + $this->assertFileExists($project_root . '/unknown/file.txt'); + $stage->create(); + $this->assertFileDoesNotExist($stage->getStageDirectory() . '/unknown/file.txt'); + } + + public function testPathRepositoriesAreIncluded(): void { + $this->createTestProjectForTemplate(TRUE); + + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + $this->assertDirectoryExists($project_root . '/custom'); + + $stage = $this->createStage(); + $stage->create(); + $this->assertDirectoryExists($stage->getStageDirectory() . '/custom'); + $stage->require(['ext-json:*']); + $stage->apply(); + $this->assertDirectoryExists($project_root . '/custom'); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php new file mode 100644 index 00000000000..7e2c5360509 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel\PathExcluder; + +use Drupal\package_manager\PathLocator; +use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase; + +/** + * @covers \Drupal\package_manager\PathExcluder\VendorHardeningExcluder + * @group package_manager + * @internal + */ +class VendorHardeningExcluderTest extends PackageManagerKernelTestBase { + + /** + * Tests that vendor hardening files are excluded from stage operations. + */ + public function testVendorHardeningFilesExcluded(): void { + // In this test, we want to perform the actual stage operations so that we + // can be sure that files are staged as expected. + $this->setSetting('package_manager_bypass_composer_stager', FALSE); + // Ensure we have an up-to-date container. + $this->container = $this->container->get('kernel')->rebuildContainer(); + + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage_dir = $stage->getStageDirectory(); + + $excluded = ['vendor/.htaccess']; + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + $this->assertFileDoesNotExist("$stage_dir/$path"); + } + + $stage->apply(); + // The excluded files should still be in the active directory. + foreach ($excluded as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php new file mode 100644 index 00000000000..d8682d60fae --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\PendingUpdatesValidator + * @group package_manager + * @internal + */ +class PendingUpdatesValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['system']; + + /** + * Tests that no error is raised if there are no pending updates. + */ + public function testNoPendingUpdates(): void { + $this->assertStatusCheckResults([]); + $this->assertResults([], PreCreateEvent::class); + } + + /** + * Tests that an error is raised if there are pending schema updates. + * + * @depends testNoPendingUpdates + */ + public function testPendingUpdateHook(): void { + // Set the installed schema version of Package Manager to its default value + // and import an empty update hook which is numbered much higher than will + // ever exist in the real world. + $this->container->get('keyvalue') + ->get('system.schema') + ->set('package_manager', \Drupal::CORE_MINIMUM_SCHEMA_VERSION); + + require_once __DIR__ . '/../../fixtures/db_update.php'; + + $result = ValidationResult::createError([ + t('Some modules have database updates pending. You should run the <a href="/update.php">database update script</a> immediately.'), + ]); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that an error is raised if there are pending post-updates. + */ + public function testPendingPostUpdate(): void { + // Make an additional post-update function available; the update registry + // will think it's pending. + require_once __DIR__ . '/../../fixtures/post_update.php'; + $result = ValidationResult::createError([ + t('Some modules have database updates pending. You should run the <a href="/update.php">database update script</a> immediately.'), + ]); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that pending updates stop an operation from being applied. + */ + public function testPendingUpdateAfterStaged(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + // Make an additional post-update function available; the update registry + // will think it's pending. + require_once __DIR__ . '/../../fixtures/post_update.php'; + $result = ValidationResult::createError([ + t('Some modules have database updates pending. You should run the <a href="/update.php">database update script</a> immediately.'), + ]); + try { + $stage->apply(); + $this->fail('Able to apply update even though there is pending update.'); + } + catch (StageEventException $exception) { + $this->assertExpectedResultsFromException([$result], $exception); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PhpExtensionsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/PhpExtensionsValidatorTest.php new file mode 100644 index 00000000000..727c9ba3292 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PhpExtensionsValidatorTest.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\PhpExtensionsValidator + * @group package_manager + * @internal + */ +class PhpExtensionsValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for ::testPhpExtensionsValidation(). + * + * @return array[] + * The test cases. + */ + public static function providerPhpExtensionsValidation(): array { + $openssl_error = ValidationResult::createError([ + t('The OpenSSL extension is not enabled, which is a security risk. See <a href="https://www.php.net/manual/en/openssl.installation.php">the PHP documentation</a> for information on how to enable this extension.'), + ]); + $xdebug_warning = ValidationResult::createWarning([ + t('Xdebug is enabled, which may have a negative performance impact on Package Manager and any modules that use it.'), + ]); + return [ + 'xdebug enabled, openssl installed' => [ + ['xdebug', 'openssl'], + [$xdebug_warning], + [], + ], + 'xdebug enabled, openssl not installed' => [ + ['xdebug'], + [$xdebug_warning, $openssl_error], + [$openssl_error], + ], + 'xdebug disabled, openssl installed' => [ + ['openssl'], + [], + [], + ], + 'xdebug disabled, openssl not installed' => [ + [], + [$openssl_error], + [$openssl_error], + ], + ]; + } + + /** + * Tests that PHP extensions' status are checked by Package Manager. + * + * @param string[] $loaded_extensions + * The names of the PHP extensions that the validator should think are + * loaded. + * @param \Drupal\package_manager\ValidationResult[] $expected_status_check_results + * The expected validation results during the status check event. + * @param \Drupal\package_manager\ValidationResult[] $expected_life_cycle_results + * The expected validation results during pre-create and pre-apply event. + * + * @dataProvider providerPhpExtensionsValidation + */ + public function testPhpExtensionsValidation(array $loaded_extensions, array $expected_status_check_results, array $expected_life_cycle_results): void { + $state = $this->container->get('state'); + // @see \Drupal\package_manager\Validator\PhpExtensionsValidator::isExtensionLoaded() + $state->set('package_manager_loaded_php_extensions', $loaded_extensions); + + $this->assertStatusCheckResults($expected_status_check_results); + $this->assertResults($expected_life_cycle_results, PreCreateEvent::class); + // To test pre-apply delete the loaded extensions in state which will allow + // the pre-create event to run without a validation error. + $state->delete('package_manager_loaded_php_extensions'); + // On post-create set the loaded extensions in state so that the pre-apply + // event will have the expected validation error. + $this->addEventTestListener(function () use ($state, $loaded_extensions) { + $state->set('package_manager_loaded_php_extensions', $loaded_extensions); + }, PostCreateEvent::class); + $this->assertResults($expected_life_cycle_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PhpTufValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/PhpTufValidatorTest.php new file mode 100644 index 00000000000..ea0eb41f39a --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/PhpTufValidatorTest.php @@ -0,0 +1,233 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\fixture_manipulator\FixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\LockFileValidator; +use Drupal\package_manager\Validator\PhpTufValidator; + +/** + * @coversDefaultClass \Drupal\package_manager\Validator\PhpTufValidator + * @group package_manager + * @internal + */ +class PhpTufValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // PHP-TUF must be enabled for this test to run. + $this->setSetting('package_manager_bypass_tuf', FALSE); + + (new ActiveFixtureManipulator()) + ->addConfig([ + 'repositories.drupal' => [ + 'type' => 'composer', + 'url' => 'https://packages.drupal.org/8', + 'tuf' => TRUE, + ], + 'allow-plugins.' . PhpTufValidator::PLUGIN_NAME => TRUE, + ]) + ->addPackage([ + 'name' => PhpTufValidator::PLUGIN_NAME, + 'type' => 'composer-plugin', + 'require' => [ + 'composer-plugin-api' => '*', + ], + 'extra' => [ + 'class' => 'PhpTufComposerPlugin', + ], + ]) + ->commitChanges(); + } + + /** + * Tests that there are no errors if the plugin is set up correctly. + */ + public function testPluginInstalledAndConfiguredProperly(): void { + $this->assertStatusCheckResults([]); + $this->assertResults([]); + } + + /** + * Tests there is an error if the plugin is not installed in the project root. + */ + public function testPluginNotInstalledInProjectRoot(): void { + (new ActiveFixtureManipulator()) + ->removePackage(PhpTufValidator::PLUGIN_NAME) + ->commitChanges(); + + $messages = [ + t('The <code>php-tuf/composer-integration</code> plugin is not installed.'), + // Composer automatically removes the plugin from the `allow-plugins` + // list when the plugin package is removed. + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ]; + $result = ValidationResult::createError($messages, t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests removing the plugin from the stage on pre-require. + */ + public function testPluginRemovedFromStagePreRequire(): void { + $this->getStageFixtureManipulator() + ->removePackage(PhpTufValidator::PLUGIN_NAME); + + $messages = [ + t('The <code>php-tuf/composer-integration</code> plugin is not installed.'), + // Composer automatically removes the plugin from the `allow-plugins` + // list when the plugin package is removed. + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ]; + $result = ValidationResult::createError($messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + $this->assertResults([$result], PreRequireEvent::class); + } + + /** + * Tests removing the plugin from the stage before applying it. + */ + public function testPluginRemovedFromStagePreApply(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + + (new FixtureManipulator()) + ->removePackage(PhpTufValidator::PLUGIN_NAME) + ->commitChanges($stage->getStageDirectory()); + + $messages = [ + t('The <code>php-tuf/composer-integration</code> plugin is not installed.'), + // Composer automatically removes the plugin from the `allow-plugins` + // list when the plugin package is removed. + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ]; + $result = ValidationResult::createError($messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + try { + $stage->apply(); + $this->fail('Expected an exception but none was thrown.'); + } + catch (StageEventException $e) { + $this->assertInstanceOf(PreApplyEvent::class, $e->event); + $this->assertValidationResultsEqual([$result], $e->event->getResults()); + } + } + + /** + * Data provider for testing invalid plugin configuration. + * + * @return array[] + * The test cases. + */ + public static function providerInvalidConfiguration(): array { + return [ + 'plugin specifically disallowed' => [ + [ + 'allow-plugins.' . PhpTufValidator::PLUGIN_NAME => FALSE, + ], + [ + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ], + ], + 'all plugins disallowed' => [ + [ + 'allow-plugins' => FALSE, + ], + [ + t('The <code>php-tuf/composer-integration</code> plugin is not listed as an allowed plugin.'), + ], + ], + 'packages.drupal.org not using TUF' => [ + [ + 'repositories.drupal' => [ + 'type' => 'composer', + 'url' => 'https://packages.drupal.org/8', + ], + ], + [ + t('TUF is not enabled for the <code>https://packages.drupal.org/8</code> repository.'), + ], + ], + ]; + } + + /** + * Data provider for testing invalid plugin configuration in the stage. + * + * @return \Generator + * The test cases. + */ + public static function providerInvalidConfigurationInStage(): \Generator { + foreach (static::providerInvalidConfiguration() as $name => $arguments) { + $arguments[] = PreRequireEvent::class; + yield "$name on pre-require" => $arguments; + + array_splice($arguments, -1, NULL, PreApplyEvent::class); + yield "$name on pre-apply" => $arguments; + } + } + + /** + * Tests errors caused by invalid plugin configuration in the project root. + * + * @param array $config + * The Composer configuration to set. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_messages + * The expected error messages. + * + * @dataProvider providerInvalidConfiguration + */ + public function testInvalidConfigurationInProjectRoot(array $config, array $expected_messages): void { + (new ActiveFixtureManipulator())->addConfig($config)->commitChanges(); + + $result = ValidationResult::createError($expected_messages, t('The active directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests errors caused by invalid plugin configuration in the stage directory. + * + * @param array $config + * The Composer configuration to set. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $expected_messages + * The expected error messages. + * @param string $event_class + * The event before which the plugin's configuration should be changed. + * + * @dataProvider providerInvalidConfigurationInStage + */ + public function testInvalidConfigurationInStage(array $config, array $expected_messages, string $event_class): void { + $listener = function (PreRequireEvent|PreApplyEvent $event) use ($config): void { + (new FixtureManipulator()) + ->addConfig($config) + ->commitChanges($event->stage->getStageDirectory()); + }; + $this->addEventTestListener($listener, $event_class); + + // LockFileValidator will complain because we have not added, removed, or + // updated any packages in the stage. In this very specific situation, it's + // okay to disable that validator to remove the interference. + if ($event_class === PreApplyEvent::class) { + $lock_file_validator = $this->container->get(LockFileValidator::class); + $this->container->get('event_dispatcher') + ->removeSubscriber($lock_file_validator); + } + + $result = ValidationResult::createError($expected_messages, t('The stage directory is not protected by PHP-TUF, which is required to use Package Manager securely.')); + $this->assertResults([$result], $event_class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php b/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php new file mode 100644 index 00000000000..db27b06efa8 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ProcessFactoryTest.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\ProcessFactory; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; + +/** + * @coversDefaultClass \Drupal\package_manager\ProcessFactory + * @group auto_updates + * @internal + */ +class ProcessFactoryTest extends PackageManagerKernelTestBase { + + /** + * Tests that the process factory prepends the PHP directory to PATH. + */ + public function testPhpDirectoryPrependedToPath(): void { + $factory = $this->container->get(ProcessFactoryInterface::class); + $this->assertInstanceOf(ProcessFactory::class, $factory); + + // Ensure that the directory of the PHP interpreter can be found. + $reflector = new \ReflectionObject($factory); + $method = $reflector->getMethod('getPhpDirectory'); + $php_dir = $method->invoke(NULL); + $this->assertNotEmpty($php_dir); + + // The process factory should always put the PHP interpreter's directory + // at the beginning of the PATH environment variable. + $env = $factory->create(['whoami'])->getEnv(); + $this->assertStringStartsWith("$php_dir:", $env['PATH']); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php b/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php new file mode 100644 index 00000000000..16bb603b189 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php @@ -0,0 +1,303 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\Logger\RfcLogLevel; +use Drupal\package_manager\ProjectInfo; + +/** + * @coversDefaultClass \Drupal\package_manager\ProjectInfo + * @group auto_updates + * @internal + */ +class ProjectInfoTest extends PackageManagerKernelTestBase { + + /** + * @covers ::getInstallableReleases + * + * @param string $fixture + * The fixture file name. + * @param string $installed_version + * The installed version core version to set. + * @param string[] $expected_versions + * The expected versions. + * + * @dataProvider providerGetInstallableReleases + */ + public function testGetInstallableReleases(string $fixture, string $installed_version, array $expected_versions): void { + [$project] = explode('.', $fixture); + $fixtures_directory = __DIR__ . '/../../fixtures/release-history/'; + if ($project === 'drupal') { + $this->setCoreVersion($installed_version); + } + else { + // Update the version and the project of the project. + $this->enableModules(['package_manager_test_update']); + $extension_info_update = [ + 'version' => $installed_version, + 'project' => 'package_manager_test_update', + ]; + // @todo Replace with use of the trait from the Update module in https://drupal.org/i/3348234. + $this->config('update_test.settings') + ->set("system_info.$project", $extension_info_update) + ->save(); + // The Update module will always request Drupal core's update XML. + $metadata_fixtures['drupal'] = $fixtures_directory . 'drupal.9.8.2.xml'; + } + $metadata_fixtures[$project] = "$fixtures_directory$fixture"; + $this->setReleaseMetadata($metadata_fixtures); + $project_info = new ProjectInfo($project); + $actual_releases = $project_info->getInstallableReleases(); + // Assert that we returned the correct releases in the expected order. + $this->assertSame($expected_versions, array_keys($actual_releases)); + // Assert that we version keys match the actual releases. + foreach ($actual_releases as $version => $release) { + $this->assertSame($version, $release->getVersion()); + } + } + + /** + * Data provider for testGetInstallableReleases(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerGetInstallableReleases(): array { + return [ + 'core, no updates' => [ + 'drupal.9.8.2.xml', + '9.8.2', + [], + ], + 'core, on supported branch, pre-release in next minor' => [ + 'drupal.9.8.0-alpha1.xml', + '9.7.1', + ['9.8.0-alpha1'], + ], + 'core, on unsupported branch, updates in multiple supported branches' => [ + 'drupal.9.8.2.xml', + '9.6.0-alpha1', + ['9.8.2', '9.8.1', '9.8.0', '9.8.0-alpha1', '9.7.1', '9.7.0', '9.7.0-alpha1'], + ], + // A test case with an unpublished release, 9.8.0, and unsupported + // release, 9.8.1, both of these releases should not be returned. + 'core, filter out unsupported and unpublished releases' => [ + 'drupal.9.8.2-unsupported_unpublished.xml', + '9.6.0-alpha1', + ['9.8.2', '9.8.0-alpha1', '9.7.1', '9.7.0', '9.7.0-alpha1'], + ], + 'core, supported branches before and after installed release' => [ + 'drupal.9.8.2.xml', + '9.8.0-alpha1', + ['9.8.2', '9.8.1', '9.8.0'], + ], + 'core, one insecure release filtered out' => [ + 'drupal.9.8.1-security.xml', + '9.8.0-alpha1', + ['9.8.1'], + ], + 'core, skip insecure releases and return secure releases' => [ + 'drupal.9.8.2-older-sec-release.xml', + '9.7.0-alpha1', + ['9.8.2', '9.8.1', '9.8.1-beta1', '9.8.0-alpha1', '9.7.1'], + ], + 'contrib, semver and legacy' => [ + 'package_manager_test_update.7.0.1.xml', + '8.x-6.0-alpha1', + ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2', '8.x-6.1', '8.x-6.0'], + ], + 'contrib, semver and legacy, some lower' => [ + 'package_manager_test_update.7.0.1.xml', + '8.x-6.1', + ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2'], + ], + 'contrib, semver and legacy, on semantic dev' => [ + 'package_manager_test_update.7.0.1.xml', + '7.0.x-dev', + ['7.0.1', '7.0.0', '7.0.0-alpha1'], + ], + 'contrib, semver and legacy, on legacy dev' => [ + 'package_manager_test_update.7.0.1.xml', + '8.x-6.x-dev', + ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2', '8.x-6.1', '8.x-6.0', '8.x-6.0-alpha1'], + ], + ]; + } + + /** + * Tests a project that is not in the codebase. + */ + public function testNewProject(): void { + $fixtures_directory = __DIR__ . '/../../fixtures/release-history/'; + $metadata_fixtures['drupal'] = $fixtures_directory . 'drupal.9.8.2.xml'; + $metadata_fixtures['package_manager_test_update'] = $fixtures_directory . 'package_manager_test_update.7.0.1.xml'; + $this->setReleaseMetadata($metadata_fixtures); + $available = update_get_available(TRUE); + $this->assertSame(['drupal'], array_keys($available)); + $this->setReleaseMetadata($metadata_fixtures); + $state = $this->container->get('state'); + // Set the state that the update module uses to store last checked time + // ensure our calls do not affect it. + $state->set('update.last_check', 123); + $project_info = new ProjectInfo('package_manager_test_update'); + $project_data = $project_info->getProjectInfo(); + // Ensure the project information is correct. + $this->assertSame('Package Manager Test Update', $project_data['title']); + $all_releases = [ + '7.0.1', + '7.0.0', + '7.0.0-alpha1', + '8.x-6.2', + '8.x-6.1', + '8.x-6.0', + '8.x-6.0-alpha1', + '7.0.x-dev', + '8.x-6.x-dev', + '8.x-5.x', + ]; + $uninstallable_releases = ['7.0.x-dev', '8.x-6.x-dev', '8.x-5.x']; + $installable_releases = array_values(array_diff($all_releases, $uninstallable_releases)); + $this->assertSame( + $all_releases, + array_keys($project_data['releases']) + ); + $this->assertSame( + $installable_releases, + array_keys($project_info->getInstallableReleases()) + ); + $this->assertNull($project_info->getInstalledVersion()); + // Ensure we have not changed the state the update module uses to store + // the last checked time. + $this->assertSame(123, $state->get('update.last_check')); + + $this->assertTrue($this->failureLogger->hasRecordThatContains('Invalid project format: Array', (string) RfcLogLevel::ERROR)); + $this->assertTrue($this->failureLogger->hasRecordThatContains('[name] => Package Manager Test Update 8.x-5.x', (string) RfcLogLevel::ERROR)); + // Prevent the logged errors from causing failures during tear-down. + $this->failureLogger->reset(); + } + + /** + * Tests a project with a status other than "published". + * + * @covers ::getInstallableReleases + */ + public function testNotPublishedProject(): void { + $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../fixtures/release-history/drupal.9.8.2_unknown_status.xml']); + $project_info = new ProjectInfo('drupal'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("The project 'drupal' can not be updated because its status is any status besides published"); + $project_info->getInstallableReleases(); + } + + /** + * Data provider for ::testInstalledVersionSafe(). + * + * @return array[] + * The test cases. + */ + public static function providerInstalledVersionSafe(): array { + $dir = __DIR__ . '/../../fixtures/release-history'; + + return [ + 'safe version' => [ + '9.8.0', + $dir . '/drupal.9.8.2.xml', + TRUE, + ], + 'unpublished version' => [ + '9.8.0', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'unsupported branch' => [ + '9.6.1', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'unsupported version' => [ + '9.8.1', + $dir . '/drupal.9.8.2-unsupported_unpublished.xml', + FALSE, + ], + 'insecure version' => [ + '9.8.0', + $dir . '/drupal.9.8.1-security.xml', + FALSE, + ], + ]; + } + + /** + * Tests checking if the currently installed version of a project is safe. + * + * @param string $installed_version + * The currently installed version of the project. + * @param string $release_xml + * The path of the release metadata. + * @param bool $expected_to_be_safe + * Whether the installed version of the project is expected to be found + * safe. + * + * @covers ::isInstalledVersionSafe + * + * @dataProvider providerInstalledVersionSafe + */ + public function testInstalledVersionSafe(string $installed_version, string $release_xml, bool $expected_to_be_safe): void { + $this->setCoreVersion($installed_version); + $this->setReleaseMetadata(['drupal' => $release_xml]); + + $project_info = new ProjectInfo('drupal'); + $this->assertSame($expected_to_be_safe, $project_info->isInstalledVersionSafe()); + } + + /** + * Data provider for testGetSupportedBranches(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerGetSupportedBranches(): array { + $dir = __DIR__ . '/../../fixtures/release-history/'; + + return [ + 'xml with supported branches' => [ + $dir . 'drupal.10.0.0.xml', + [ + '9.5.', + '9.6.', + '9.7.', + '10.0.', + ], + ], + 'xml with supported branches not set' => [ + $dir . 'drupal.9.8.1-supported_branches_not_set.xml', + [], + ], + 'xml with empty supported branches' => [ + $dir . 'drupal.9.8.1-empty_supported_branches.xml', + [ + '', + ], + ], + ]; + } + + /** + * @covers ::getSupportedBranches + * + * @param string $release_xml + * The path of the release metadata. + * @param string[] $expected_supported_branches + * The expected supported branches. + * + * @dataProvider providerGetSupportedBranches + */ + public function testGetSupportedBranches(string $release_xml, array $expected_supported_branches): void { + $this->setReleaseMetadata(['drupal' => $release_xml]); + $project_info = new ProjectInfo('drupal'); + $this->assertSame($expected_supported_branches, $project_info->getSupportedBranches()); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php new file mode 100644 index 00000000000..e066100e188 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/RsyncValidatorTest.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\RsyncValidator; +use PhpTuf\ComposerStager\API\Exception\LogicException; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; +use Symfony\Component\DependencyInjection\Reference; + +/** + * @covers \Drupal\package_manager\Validator\RsyncValidator + * @group package_manager + * @internal + */ +class RsyncValidatorTest extends PackageManagerKernelTestBase { + + /** + * The mocked executable finder. + * + * @var \PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface + */ + private $executableFinder; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + // Set up a mocked executable finder which will always be re-injected into + // the validator when the container is rebuilt. + $this->executableFinder = $this->prophesize(ExecutableFinderInterface::class); + $this->executableFinder->find('rsync')->willReturn('/path/to/rsync'); + + parent::setUp(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + $container->set('mock_executable_finder', $this->executableFinder->reveal()); + + $container->getDefinition(RsyncValidator::class) + ->setArgument('$executableFinder', new Reference('mock_executable_finder')); + } + + /** + * Tests that the stage cannot be created if rsync is selected, but not found. + */ + public function testPreCreateFailsIfRsyncNotFound(): void { + /** @var \PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface $translatable_factory */ + $translatable_factory = $this->container->get(TranslatableFactoryInterface::class); + $message = $translatable_factory->createTranslatableMessage('Nope!'); + $this->executableFinder->find('rsync')->willThrow(new LogicException($message)); + + $result = ValidationResult::createError([ + t('<code>rsync</code> is not available.'), + ]); + $this->assertResults([$result], PreCreateEvent::class); + + $this->enableModules(['help']); + + $result = ValidationResult::createError([ + t('<code>rsync</code> is not available. See the <a href="/admin/help/package_manager#package-manager-faq-rsync">Package Manager help</a> for more information on how to resolve this.'), + ]); + $this->assertResults([$result], PreCreateEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/ServicesTest.php b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php new file mode 100644 index 00000000000..a1db5a517c1 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\ExecutableFinder; +use Drupal\package_manager\LoggingBeginner; +use Drupal\package_manager\LoggingCommitter; +use Drupal\package_manager\LoggingStager; +use Drupal\package_manager\ProcessFactory; +use Drupal\package_manager\TranslatableStringFactory; +use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; +use PhpTuf\ComposerStager\API\Process\Factory\ProcessFactoryInterface; +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; + +/** + * Tests that Package Manager services are wired correctly. + * + * @group package_manager + * @internal + */ +class ServicesTest extends KernelTestBase { + + use AssertPreconditionsTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager', 'update']; + + /** + * Tests that Package Manager's public services can be instantiated. + */ + public function testPackageManagerServices(): void { + // Ensure that any overridden Composer Stager services were overridden + // correctly. + $overrides = [ + ExecutableFinderInterface::class => ExecutableFinder::class, + ProcessFactoryInterface::class => ProcessFactory::class, + TranslatableFactoryInterface::class => TranslatableStringFactory::class, + BeginnerInterface::class => LoggingBeginner::class, + StagerInterface::class => LoggingStager::class, + CommitterInterface::class => LoggingCommitter::class, + ]; + foreach ($overrides as $interface => $expected_class) { + $this->assertInstanceOf($expected_class, $this->container->get($interface)); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php new file mode 100644 index 00000000000..850d8ce03d9 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php @@ -0,0 +1,66 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\SettingsValidator + * @group package_manager + * @internal + */ +class SettingsValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testSettingsValidation(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerSettingsValidation(): array { + $result = ValidationResult::createError([t('The <code>update_fetch_with_http_fallback</code> setting must be disabled.')]); + + return [ + 'HTTP fallback enabled' => [TRUE, [$result]], + 'HTTP fallback disabled' => [FALSE, []], + ]; + } + + /** + * Tests settings validation before starting an update. + * + * @param bool $setting + * The value of the update_fetch_with_http_fallback setting. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerSettingsValidation + */ + public function testSettingsValidation(bool $setting, array $expected_results): void { + $this->setSetting('update_fetch_with_http_fallback', $setting); + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests settings validation during pre-apply. + * + * @param bool $setting + * The value of the update_fetch_with_http_fallback setting. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerSettingsValidation + */ + public function testSettingsValidationDuringPreApply(bool $setting, array $expected_results): void { + $this->addEventTestListener(function () use ($setting): void { + $this->setSetting('update_fetch_with_http_fallback', $setting); + }); + $this->assertResults($expected_results, PreApplyEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StageBaseTest.php b/core/modules/package_manager/tests/src/Kernel/StageBaseTest.php new file mode 100644 index 00000000000..7fca967a932 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StageBaseTest.php @@ -0,0 +1,831 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Component\Datetime\Time; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Extension\ModuleUninstallValidatorException; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Exception\ApplyFailedException; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageFailureMarkerException; +use Drupal\package_manager\FailureMarker; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\Validator\WritableFileSystemValidator; +use Drupal\package_manager_bypass\LoggingBeginner; +use Drupal\package_manager_bypass\LoggingCommitter; +use Drupal\package_manager_bypass\NoOpStager; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Exception\ExceptionInterface; +use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException; +use PhpTuf\ComposerStager\API\Exception\PreconditionException; +use PhpTuf\ComposerStager\API\Precondition\Service\PreconditionInterface; +use Psr\Log\LogLevel; +use ColinODell\PsrTestLogger\TestLogger; + +/** + * @coversDefaultClass \Drupal\package_manager\StageBase + * @covers \Drupal\package_manager\PackageManagerUninstallValidator + * @group package_manager + * @internal + */ +class StageBaseTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = ['package_manager_test_validation']; + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + $container->getDefinition('datetime.time') + ->setClass(TestTime::class); + + // Since this test adds arbitrary event listeners that aren't services, we + // need to ensure they will persist even if the container is rebuilt when + // staged changes are applied. + $container->getDefinition('event_dispatcher')->addTag('persist'); + } + + /** + * Data provider for testLoggedOnError(). + * + * @return string[][] + * The test cases. + */ + public static function providerLoggedOnError(): array { + return [ + [PreCreateEvent::class], + [PostCreateEvent::class], + [PreRequireEvent::class], + [PostRequireEvent::class], + [PreApplyEvent::class], + [PostApplyEvent::class], + ]; + } + + /** + * @covers \Drupal\package_manager\StageBase::dispatch + * + * @dataProvider providerLoggedOnError + * + * @param string $event_class + * The event class to throw an exception on. + */ + public function testLoggedOnError(string $event_class): void { + $exception = new \Exception("This should be logged!"); + TestSubscriber::setException($exception, $event_class); + + $stage = $this->createStage(); + $logger = new TestLogger(); + $stage->setLogger($logger); + + try { + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + $stage->postApply(); + $this->fail('Expected an exception to be thrown, but none was.'); + } + catch (StageEventException $e) { + $this->assertInstanceOf($event_class, $e->event); + + $predicate = function (array $record) use ($e): bool { + $context = $record['context']; + return $context['@message'] === $e->getMessage() && str_contains($context['@backtrace_string'], 'testLoggedOnError'); + }; + $this->assertTrue($logger->hasRecordThatPasses($predicate, LogLevel::ERROR)); + } + } + + /** + * @covers ::getMetadata + * @covers ::setMetadata + */ + public function testMetadata(): void { + $stage = $this->createStage(); + $stage->create(); + $this->assertNull($stage->getMetadata('new_key')); + $stage->setMetadata('new_key', 'value'); + $this->assertSame('value', $stage->getMetadata('new_key')); + $stage->destroy(); + + // Ensure that metadata associated with the previous stage was deleted. + $stage = $this->createStage(); + $stage->create(); + $this->assertNull($stage->getMetadata('new_key')); + $stage->destroy(); + + // Ensure metadata cannot be accessed or set unless the stage has been + // claimed. + $stage = $this->createStage(); + try { + $stage->getMetadata('new_key'); + $this->fail('Expected an ownership exception, but none was thrown.'); + } + catch (\LogicException $e) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $e->getMessage()); + } + + try { + $stage->setMetadata('new_key', 'value'); + $this->fail('Expected an ownership exception, but none was thrown.'); + } + catch (\LogicException $e) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $e->getMessage()); + } + } + + /** + * @covers ::getStageDirectory + */ + public function testGetStageDirectory(): void { + // In this test, we're working with paths that (probably) don't exist in + // the file system at all, so we don't want to validate that the file system + // is writable when creating stages. + $validator = $this->container->get(WritableFileSystemValidator::class); + $this->container->get('event_dispatcher')->removeSubscriber($validator); + + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + + $stage = $this->createStage(); + $id = $stage->create(); + $stage_dir = $stage->getStageDirectory(); + $this->assertStringStartsWith($path_locator->getStagingRoot() . '/', $stage_dir); + $this->assertStringEndsWith("/$id", $stage_dir); + // If the stage root directory is changed, the existing stage shouldn't be + // affected... + $active_dir = $path_locator->getProjectRoot(); + $new_staging_root = $this->testProjectRoot . DIRECTORY_SEPARATOR . 'junk'; + if (!is_dir($new_staging_root)) { + mkdir($new_staging_root); + } + $path_locator->setPaths($active_dir, "$active_dir/vendor", '', $new_staging_root); + $this->assertSame($stage_dir, $stage->getStageDirectory()); + $stage->destroy(); + // ...but a new stage should be. + $stage = $this->createStage(); + $another_id = $stage->create(); + $this->assertNotSame($id, $another_id); + $stage_dir = $stage->getStageDirectory(); + $this->assertStringStartsWith(realpath($new_staging_root), $stage_dir); + $this->assertStringEndsWith("/$another_id", $stage_dir); + } + + /** + * @covers ::getStageDirectory + */ + public function testUncreatedGetStageDirectory(): void { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Drupal\package_manager\StageBase::getStageDirectory() cannot be called because the stage has not been created or claimed.'); + $this->createStage()->getStageDirectory(); + } + + /** + * Data provider for testDestroyDuringApply(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerDestroyDuringApply(): array { + $error_message_while_being_applied = 'Cannot destroy the stage directory while it is being applied to the active directory.'; + return [ + 'force destroy on pre-apply, fresh' => [ + PreApplyEvent::class, + TRUE, + 1, + $error_message_while_being_applied, + ], + 'destroy on pre-apply, fresh' => [ + PreApplyEvent::class, + FALSE, + 1, + $error_message_while_being_applied, + ], + 'force destroy on pre-apply, stale' => [ + PreApplyEvent::class, + TRUE, + 7200, + 'Stage directory does not exist', + ], + 'destroy on pre-apply, stale' => [ + PreApplyEvent::class, + FALSE, + 7200, + 'Stage directory does not exist', + ], + 'force destroy on post-apply, fresh' => [ + PostApplyEvent::class, + TRUE, + 1, + $error_message_while_being_applied, + ], + 'destroy on post-apply, fresh' => [ + PostApplyEvent::class, + FALSE, + 1, + $error_message_while_being_applied, + ], + 'force destroy on post-apply, stale' => [ + PostApplyEvent::class, + TRUE, + 7200, + NULL, + ], + 'destroy on post-apply, stale' => [ + PostApplyEvent::class, + FALSE, + 7200, + NULL, + ], + ]; + } + + /** + * Tests destroying a stage while applying it. + * + * @param string $event_class + * The event class for which to attempt to destroy the stage. + * @param bool $force + * Whether the stage should be force destroyed. + * @param int $time_offset + * How many simulated seconds should have elapsed between the PreApplyEvent + * being dispatched and the attempt to destroy the stage. + * @param string|null $expected_exception_message + * The expected exception message string if an exception is expected, or + * NULL if no exception message was expected. + * + * @dataProvider providerDestroyDuringApply + */ + public function testDestroyDuringApply(string $event_class, bool $force, int $time_offset, ?string $expected_exception_message): void { + $listener = function (StageEvent $event) use ($force, $time_offset): void { + // Simulate that a certain amount of time has passed since we started + // applying staged changes. After a point, it should be possible to + // destroy the stage even if it hasn't finished. + TestTime::$offset = $time_offset; + + // No real-life event subscriber should try to destroy the stage while + // handling another event. The only reason we're doing it here is to + // simulate an attempt to destroy the stage while it's being applied, for + // testing purposes. + $event->stage->destroy($force); + LoggingCommitter::setException( + PreconditionException::class, + $this->createMock(PreconditionInterface::class), + $this->createComposeStagerMessage('Stage directory does not exist'), + ); + }; + $this->addEventTestListener($listener, $event_class, 0); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + if ($expected_exception_message) { + $this->expectException(StageException::class); + $this->expectExceptionMessage($expected_exception_message); + } + $stage->apply(); + + // If the stage was successfully destroyed by the event handler (i.e., the + // stage has been applying for too long and is therefore considered stale), + // the postApply() method should fail because the stage is not claimed. + if ($stage->isAvailable()) { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Stage must be claimed before performing any operations on it.'); + } + $stage->postApply(); + } + + /** + * Test uninstalling any module while the staged changes are being applied. + */ + public function testUninstallModuleDuringApply(): void { + $listener = function (PreApplyEvent $event): void { + $this->assertTrue($event->stage->isApplying()); + + // Trying to uninstall any module while the stage is being applied should + // result in a module uninstall validation error. + try { + $this->container->get('module_installer') + ->uninstall(['package_manager_bypass']); + $this->fail('Expected an exception to be thrown while uninstalling a module.'); + } + catch (ModuleUninstallValidatorException $e) { + $this->assertStringContainsString('Modules cannot be uninstalled while Package Manager is applying staged changes to the active code base.', $e->getMessage()); + } + }; + $this->addEventTestListener($listener); + + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage->apply(); + } + + /** + * Tests that Composer Stager is invoked with a long timeout. + */ + public function testTimeouts(): void { + $stage = $this->createStage(); + $stage->create(420); + $stage->require(['ext-json:*']); + $stage->apply(); + + $timeouts = [ + // The beginner was given an explicit timeout. + BeginnerInterface::class => 420, + // The stager should be called with a timeout of 300 seconds, which is + // longer than Composer Stager's default timeout of 120 seconds. + StagerInterface::class => 300, + // The committer should have been called with an even longer timeout, + // since it's the most failure-sensitive operation. + CommitterInterface::class => 600, + ]; + foreach ($timeouts as $service_id => $expected_timeout) { + $invocations = $this->container->get($service_id)->getInvocationArguments(); + + // The services should have been called with the expected timeouts. + $expected_count = 1; + if ($service_id === StagerInterface::class) { + // Stage::require() calls Stager::stage() twice, once to change the + // version constraints in composer.json, and again to actually update + // the installed dependencies. + $expected_count = 2; + } + $this->assertCount($expected_count, $invocations); + $this->assertSame($expected_timeout, end($invocations[0])); + } + } + + /** + * Data provider for testCommitException(). + * + * @return \string[][] + * The test cases. + */ + public static function providerCommitException(): array { + return [ + 'RuntimeException to ApplyFailedException' => [ + 'RuntimeException', + ApplyFailedException::class, + ], + 'InvalidArgumentException' => [ + InvalidArgumentException::class, + StageException::class, + ], + 'PreconditionException' => [ + PreconditionException::class, + StageException::class, + ], + 'Exception' => [ + 'Exception', + ApplyFailedException::class, + ], + ]; + } + + /** + * Tests exception handling during calls to Composer Stager commit. + * + * @param string $thrown_class + * The throwable class that should be thrown by Composer Stager. + * @param string $expected_class + * The expected exception class, if different from $thrown_class. + * + * @dataProvider providerCommitException + */ + public function testCommitException(string $thrown_class, string $expected_class): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + + $throwable_arguments = [ + 'A very bad thing happened', + 123, + ]; + // Composer Stager's exception messages are usually translatable, so they + // need to be wrapped by a TranslatableMessage object. + if (is_subclass_of($thrown_class, ExceptionInterface::class)) { + $throwable_arguments[0] = $this->createComposeStagerMessage($throwable_arguments[0]); + } + // PreconditionException requires a preconditions object. + if ($thrown_class === PreconditionException::class) { + array_unshift($throwable_arguments, $this->createMock(PreconditionInterface::class)); + } + LoggingCommitter::setException($thrown_class, ...$throwable_arguments); + + try { + $stage->apply(); + $this->fail('Expected an exception.'); + } + catch (\Throwable $exception) { + $this->assertInstanceOf($expected_class, $exception); + $this->assertSame(123, $exception->getCode()); + + // This needs to be done because we always use the message from + // \Drupal\package_manager\Stage::getFailureMarkerMessage() when throwing + // ApplyFailedException. + if ($expected_class == ApplyFailedException::class) { + $this->assertMatchesRegularExpression("/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by $thrown_class, with this message: A very bad thing happened\nBacktrace:\n#0 .*/", $exception->getMessage()); + } + else { + $this->assertSame('A very bad thing happened', $exception->getMessage()); + } + + $failure_marker = $this->container->get(FailureMarker::class); + if ($exception instanceof ApplyFailedException) { + $this->assertFileExists($failure_marker->getPath()); + $this->assertFalse($stage->isApplying()); + } + else { + $failure_marker->assertNotExists(); + } + } + } + + /** + * Tests that if a stage fails to apply, another stage cannot be created. + */ + public function testFailureMarkerPreventsCreate(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + + // Make the committer throw an exception, which should cause the failure + // marker to be present. + $thrown_message = 'Thrown by the committer.'; + LoggingCommitter::setException(\Exception::class, $thrown_message); + try { + $stage->apply(); + $this->fail('Expected an exception.'); + } + catch (ApplyFailedException $e) { + $this->assertStringContainsString($thrown_message, $e->getMessage()); + $this->assertFalse($stage->isApplying()); + } + $stage->destroy(); + + // Even through the previous stage was destroyed, we cannot create a new one + // because the failure marker is still there. + $stage = $this->createStage(); + try { + $stage->create(); + $this->fail('Expected an exception.'); + } + catch (StageFailureMarkerException $e) { + $this->assertMatchesRegularExpression('/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by Exception, with this message: ' . $thrown_message . "\nBacktrace:\n#0 .*/", $e->getMessage()); + $this->assertFalse($stage->isApplying()); + } + + // If the failure marker is cleared, we should be able to create the stage + // without issue. + $this->container->get(FailureMarker::class)->clear(); + $stage->create(); + } + + /** + * Tests that the failure marker file doesn't exist if apply succeeds. + * + * @see ::testCommitException + */ + public function testNoFailureFileOnSuccess(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + $stage->apply(); + + $this->container->get(FailureMarker::class) + ->assertNotExists(); + } + + /** + * Data provider for testStoreDestroyInfo(). + * + * @return \string[][] + * The test cases. + */ + public static function providerStoreDestroyInfo(): array { + return [ + 'Changes applied' => [ + FALSE, + TRUE, + NULL, + 'This operation has already been applied.', + ], + 'Changes not applied and forced' => [ + TRUE, + FALSE, + NULL, + 'This operation was canceled by another user.', + ], + 'Changes not applied and not forced' => [ + FALSE, + FALSE, + NULL, + 'This operation was already canceled.', + ], + 'Changes applied, with a custom exception message.' => [ + FALSE, + TRUE, + t('Stage destroyed with a custom message.'), + 'Stage destroyed with a custom message.', + ], + 'Changes not applied and forced, with a custom exception message.' => [ + TRUE, + FALSE, + t('Stage destroyed with a custom message.'), + 'Stage destroyed with a custom message.', + ], + 'Changes not applied and not forced, with a custom exception message.' => [ + FALSE, + FALSE, + t('Stage destroyed with a custom message.'), + 'Stage destroyed with a custom message.', + ], + ]; + } + + /** + * Tests exceptions thrown because of previously destroyed stage. + * + * @param bool $force + * Whether the stage was forcefully destroyed. + * @param bool $changes_applied + * Whether the changes are applied. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message + * A message about why the stage was destroyed or null. + * @param string $expected_exception_message + * The expected exception message string. + * + * @dataProvider providerStoreDestroyInfo + */ + public function testStoreDestroyInfo(bool $force, bool $changes_applied, ?TranslatableMarkup $message, string $expected_exception_message): void { + $stage = $this->createStage(); + $stage_id = $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $tempstore = $this->container->get('tempstore.shared'); + // Simulate whether ::apply() has run or not. + // @see \Drupal\package_manager\Stage::TEMPSTORE_CHANGES_APPLIED + $tempstore->get('package_manager_stage')->set('changes_applied', $changes_applied); + $stage->destroy($force, $message); + + // Prove the first stage was destroyed: a second stage can be created + // without an exception being thrown. + $stage2 = $this->createStage(); + $stage2->create(); + + // Claiming the first stage always fails in this test because it was + // destroyed, but the exception message depends on why it was destroyed. + $this->expectException(StageException::class); + $this->expectExceptionMessage($expected_exception_message); + $stage->claim($stage_id); + } + + /** + * Tests exception message once temp store message has expired. + */ + public function testTempStoreMessageExpired(): void { + $stage = $this->createStage(); + $stage_id = $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->destroy(TRUE, t('Force destroy stage.')); + + // Delete the tempstore message stored for the previously destroyed stage. + $tempstore = $this->container->get('tempstore.shared'); + // @see \Drupal\package_manager\Stage::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX + $tempstore->get('package_manager_stage')->delete('TEMPSTORE_DESTROYED_STAGES_INFO' . $stage_id); + + // Claiming the stage will fail, but we won't get the message we set in + // \Drupal\package_manager\Stage::storeDestroyInfo() as we are deleting it + // above. + $this->expectException(StageException::class); + $this->expectExceptionMessage('Cannot claim the stage because no stage has been created.'); + $stage->claim($stage_id); + } + + /** + * Tests running apply and post-apply in the same request. + */ + public function testApplyAndPostApplyInSameRequest(): void { + $stage = $this->createStage(); + + $logger = new TestLogger(); + $stage->setLogger($logger); + $warning_message = 'Post-apply tasks are running in the same request during which staged changes were applied to the active code base. This can result in unpredictable behavior.'; + + // Run apply and post-apply in the same request (i.e., the same request + // time), and ensure the warning is logged. + $stage->create(); + $stage->require(['drupal/core:9.8.1']); + $stage->apply(); + $stage->postApply(); + $this->assertTrue($logger->hasRecord($warning_message, LogLevel::WARNING)); + $stage->destroy(); + + $logger->reset(); + $stage->create(); + $stage->require(['drupal/core:9.8.2']); + $stage->apply(); + // Simulate post-apply taking place in another request by simulating a + // request time 30 seconds after apply started. + TestTime::$offset = 30; + $stage->postApply(); + $this->assertFalse($logger->hasRecord($warning_message, LogLevel::WARNING)); + } + + /** + * Data provider for ::testFailureDuringComposerStagerOperations(). + * + * @return array[] + * The test cases. + */ + public static function providerFailureDuringComposerStagerOperations(): array { + return [ + [LoggingBeginner::class], + [NoOpStager::class], + [LoggingCommitter::class], + ]; + } + + /** + * Tests when Composer Stager throws an exception during an operation. + * + * @param class-string $throwing_class + * The fully qualified name of the Composer Stager class that should throw + * an exception. It is expected to have a static ::setException() method, + * provided by \Drupal\package_manager_bypass\ComposerStagerExceptionTrait. + * + * @dataProvider providerFailureDuringComposerStagerOperations + */ + public function testFailureDuringComposerStagerOperations(string $throwing_class): void { + $exception_message = "$throwing_class is angry!"; + $throwing_class::setException(\Exception::class, $exception_message, 1024); + + $expected_message = preg_quote($exception_message); + if ($throwing_class === LoggingCommitter::class) { + $expected_message = "/^Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup. Caused by Exception, with this message: $expected_message\nBacktrace:\n#0 .*/"; + } + else { + $expected_message = "/^$expected_message$/"; + } + + $stage = $this->createStage(); + try { + $stage->create(); + $stage->require(['ext-json:*']); + $stage->apply(); + $this->fail('Expected an exception to be thrown, but it was not.'); + } + catch (StageException $e) { + $this->assertMatchesRegularExpression($expected_message, $e->getMessage()); + $this->assertSame(1024, $e->getCode()); + $this->assertInstanceOf(\Exception::class, $e->getPrevious()); + } + } + + /** + * Tests that paths to exclude are collected before create and apply. + */ + public function testCollectPathsToExclude(): void { + $this->addEventTestListener(function (CollectPathsToExcludeEvent $event): void { + $event->add('exclude/me'); + }, CollectPathsToExcludeEvent::class); + + // On pre-create and pre-apply, ensure that the excluded path is known to + // the event. + $asserted = FALSE; + $assert_excluded = function (object $event) use (&$asserted): void { + $this->assertContains('exclude/me', $event->excludedPaths->getAll()); + // Use this to confirm that this listener was actually called. + $asserted = TRUE; + }; + $this->addEventTestListener($assert_excluded, PreCreateEvent::class); + $this->addEventTestListener($assert_excluded); + + $stage = $this->createStage(); + $stage->create(); + $this->assertTrue($asserted); + $asserted = FALSE; + $stage->require(['ext-json:*']); + $stage->apply(); + $this->assertTrue($asserted); + } + + /** + * Tests that the failure marker file is excluded using a relative path. + */ + public function testFailureMarkerFileExcluded(): void { + $this->assertResults([]); + /** @var \Drupal\package_manager_bypass\LoggingCommitter $committer */ + $committer = $this->container->get(CommitterInterface::class); + $committer_args = $committer->getInvocationArguments(); + $this->assertCount(1, $committer_args); + $this->assertContains('PACKAGE_MANAGER_FAILURE.yml', $committer_args[0][2]); + } + + /** + * Tests that if a stage fails to get paths to exclude, throws a stage exception. + */ + public function testFailureCollectPathsToExclude(): void { + $project_root = $this->container->get(PathLocator::class)->getProjectRoot(); + unlink($project_root . '/composer.json'); + $this->expectException(StageException::class); + $this->expectExceptionMessage("composer.json not found."); + $this->createStage()->create(); + } + + /** + * Tests that if apply fails to get paths to exclude, throws a stage exception. + */ + public function testFailureCollectPathsToExcludeOnApply(): void { + $stage = $this->createStage(); + $stage->create(); + $stage->require(['drupal/random']); + $this->expectException(StageException::class); + $this->expectExceptionMessage("composer.json not found."); + unlink($stage->getStageDirectory() . '/composer.json'); + $stage->apply(); + } + + /** + * @covers ::stageDirectoryExists + */ + public function testStageDirectoryExists(): void { + // Ensure that stageDirectoryExists() returns an accurate result during + // pre-create. + $listener = function (StageEvent $event): void { + $stage = $event->stage; + // The directory should not exist yet, because we are still in pre-create. + $this->assertDirectoryDoesNotExist($stage->getStageDirectory()); + $this->assertFalse($stage->stageDirectoryExists()); + }; + $this->addEventTestListener($listener, PreCreateEvent::class); + + $stage = $this->createStage(); + $this->assertFalse($stage->stageDirectoryExists()); + $stage->create(); + $this->assertTrue($stage->stageDirectoryExists()); + } + + /** + * Tests that destroyed stage directories are actually deleted during cron. + * + * @covers ::destroy + * @covers \Drupal\package_manager\Plugin\QueueWorker\Cleaner + */ + public function testStageDirectoryDeletedDuringCron(): void { + $stage = $this->createStage(); + $stage->create(); + $dir = $stage->getStageDirectory(); + $this->assertDirectoryExists($dir); + $stage->destroy(); + // The stage directory should still exist, but the stage should be + // available. + $this->assertTrue($stage->isAvailable()); + $this->assertDirectoryExists($dir); + + $this->container->get('cron')->run(); + $this->assertDirectoryDoesNotExist($dir); + } + +} + +/** + * A test-only implementation of the time service. + */ +class TestTime extends Time { + + /** + * An offset to add to the request time. + * + * @var int + */ + public static $offset = 0; + + /** + * {@inheritdoc} + */ + public function getRequestTime() { + return parent::getRequestTime() + static::$offset; + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php new file mode 100644 index 00000000000..8782d27a44c --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreOperationStageEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\ValidationResult; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Tests that the stage fires events during its lifecycle. + * + * @covers \Drupal\package_manager\Event\StageEvent + * @group package_manager + * @internal + */ +class StageEventsTest extends PackageManagerKernelTestBase implements EventSubscriberInterface { + + /** + * The events that were fired, in the order they were fired. + * + * @var string[] + */ + private $events = []; + + /** + * The stage under test. + * + * @var \Drupal\package_manager\StageBase + */ + private $stage; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->stage = $this->createStage(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container): void { + parent::register($container); + + // Since this test adds arbitrary event listeners that aren't services, we + // need to ensure they will persist even if the container is rebuilt when + // staged changes are applied. + $container->getDefinition('event_dispatcher')->addTag('persist'); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PreCreateEvent::class => 'handleEvent', + PostCreateEvent::class => 'handleEvent', + PreRequireEvent::class => 'handleEvent', + PostRequireEvent::class => 'handleEvent', + PreApplyEvent::class => 'handleEvent', + PostApplyEvent::class => 'handleEvent', + ]; + } + + /** + * Handles a stage life cycle event. + * + * @param \Drupal\package_manager\Event\StageEvent $event + * The event object. + */ + public function handleEvent(StageEvent $event): void { + $this->events[] = get_class($event); + + // The event should have a reference to the stage which fired it. + $this->assertSame($event->stage, $this->stage); + } + + /** + * Tests that the stage fires life cycle events in a specific order. + */ + public function testEvents(): void { + $this->container->get('event_dispatcher')->addSubscriber($this); + + $this->stage->create(); + $this->stage->require(['ext-json:*']); + $this->stage->apply(); + $this->stage->postApply(); + $this->stage->destroy(); + + $this->assertSame($this->events, [ + PreCreateEvent::class, + PostCreateEvent::class, + PreRequireEvent::class, + PostRequireEvent::class, + PreApplyEvent::class, + PostApplyEvent::class, + ]); + } + + /** + * Data provider for testValidationResults(). + * + * @return string[][] + * The test cases. + */ + public static function providerValidationResults(): array { + return [ + 'PreCreateEvent' => [PreCreateEvent::class], + 'PreRequireEvent' => [PreRequireEvent::class], + 'PreApplyEvent' => [PreApplyEvent::class], + ]; + } + + /** + * Tests that an exception is thrown if an event has validation results. + * + * @param string $event_class + * The event class to test. + * + * @dataProvider providerValidationResults + */ + public function testValidationResults(string $event_class): void { + $error_messages = [t('Burn, baby, burn')]; + // Set up an event listener which will only flag an error for the event + // class under test. + $handler = function (StageEvent $event) use ($event_class, $error_messages): void { + if (get_class($event) === $event_class) { + if ($event instanceof PreOperationStageEvent) { + $event->addError($error_messages); + } + } + }; + $this->addEventTestListener($handler, $event_class); + + $result = ValidationResult::createError($error_messages); + $this->assertResults([$result], $event_class); + } + + /** + * Tests adding validation results to events. + */ + public function testAddResult(): void { + $stage = $this->createStage(); + + $error = ValidationResult::createError([ + t('Burn, baby, burn!'), + ]); + $warning = ValidationResult::createWarning([ + t('The path ahead is scary...'), + ]); + $excluded_paths = $this->createMock(PathListInterface::class); + + // Status check events can accept both errors and warnings. + $event = new StatusCheckEvent($stage, $excluded_paths); + $event->addResult($error); + $event->addResult($warning); + $this->assertSame([$error, $warning], $event->getResults()); + + // Other stage events will accept errors, but throw an exception if you try + // to add a warning. + $event = new PreCreateEvent($stage, $excluded_paths); + $event->addResult($error); + $this->assertSame([$error], $event->getResults()); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Only errors are allowed.'); + $event->addResult($warning); + } + + /** + * Tests that pre- and post-require events have access to the package lists. + */ + public function testPackageListsAvailableToRequireEvents(): void { + $listener = function (object $event): void { + $expected_runtime = ['drupal/core' => '9.8.2']; + $expected_dev = ['drupal/core-dev' => '9.8.2']; + + /** @var \Drupal\package_manager\Event\PreRequireEvent|\Drupal\package_manager\Event\PostRequireEvent $event */ + $this->assertSame($expected_runtime, $event->getRuntimePackages()); + $this->assertSame($expected_dev, $event->getDevPackages()); + }; + $this->addEventTestListener($listener, PreRequireEvent::class); + $this->addEventTestListener($listener, PostRequireEvent::class); + + $this->stage->create(); + $this->stage->require(['drupal/core:9.8.2'], ['drupal/core-dev:9.8.2']); + } + + /** + * Tests exception is thrown if error is not added before stopPropagation(). + */ + public function testExceptionIfNoErrorBeforeStopPropagation(): void { + $listener = function (PreCreateEvent $event): void { + $event->stopPropagation(); + }; + $this->addEventTestListener($listener, PreCreateEvent::class); + + $this->expectException(StageEventException::class); + $this->expectExceptionMessage('Event propagation stopped without any errors added to the event. This bypasses the package_manager validation system.'); + $stage = $this->createStage(); + $stage->create(); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php b/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php new file mode 100644 index 00000000000..e55c472e2ba --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php @@ -0,0 +1,242 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\Exception\StageOwnershipException; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * Tests that ownership of the stage is enforced. + * + * @group package_manager + * @internal + */ +class StageOwnershipTest extends PackageManagerKernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'system', + 'user', + 'package_manager_test_validation', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + } + + /** + * Tests only the owner of stage can perform operations, even if logged out. + */ + public function testOwnershipEnforcedWhenLoggedOut(): void { + $this->assertOwnershipIsEnforced($this->createStage(), $this->createStage()); + } + + /** + * Tests only the owner of stage can perform operations. + */ + public function testOwnershipEnforcedWhenLoggedIn(): void { + $user_1 = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($user_1); + + $will_create = $this->createStage(); + // Rebuild the container so that the shared tempstore factory is made + // properly aware of the new current user ($user_2) before another stage + // is created. + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + $user_2 = $this->createUser(); + $this->setCurrentUser($user_2); + $this->assertOwnershipIsEnforced($will_create, $this->createStage()); + } + + /** + * Asserts that ownership is enforced across stage directories. + * + * @param \Drupal\Tests\package_manager\Kernel\TestStage $will_create + * The stage that will be created, and owned by the current user or session. + * @param \Drupal\Tests\package_manager\Kernel\TestStage $never_create + * The stage that will not be created, but should still respect the + * ownership and status of the other stage. + */ + private function assertOwnershipIsEnforced(TestStage $will_create, TestStage $never_create): void { + // Before the stage directory is created, isAvailable() should return + // TRUE. + $this->assertTrue($will_create->isAvailable()); + $this->assertTrue($never_create->isAvailable()); + + $stage_id = $will_create->create(); + // Both stage directories should be considered unavailable (i.e., cannot + // be created until the existing one is destroyed first). + $this->assertFalse($will_create->isAvailable()); + $this->assertFalse($never_create->isAvailable()); + + // We should get an error if we try to create the stage directory again, + // regardless of who owns it. + foreach ([$will_create, $never_create] as $stage) { + try { + $stage->create(); + $this->fail("Able to create a stage that already exists."); + } + catch (StageException $exception) { + $this->assertSame('Cannot create a new stage because one already exists.', $exception->getMessage()); + } + } + + try { + $never_create->claim($stage_id); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because it is not owned by the current user or session.', $exception->getMessage()); + } + + // Only the stage's owner should be able to move it through its life cycle. + $callbacks = [ + 'require' => [ + ['vendor/lib:0.0.1'], + ], + 'apply' => [], + 'postApply' => [], + 'destroy' => [], + ]; + foreach ($callbacks as $method => $arguments) { + try { + $never_create->$method(...$arguments); + $this->fail("Able to call '$method' on a stage that was never created."); + } + catch (\LogicException $exception) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $exception->getMessage()); + } + // The call should succeed on the created stage. + $will_create->$method(...$arguments); + } + } + + /** + * Tests that the stage is owned by the person who calls create() on it. + */ + public function testStageOwnedByCreator(): void { + // Even if the stage is instantiated before anyone is logged in, it should + // still be owned (and claimable) by the user who called create() on it. + $stage = $this->createStage(); + + $account = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($account); + $id = $stage->create(); + $this->createStage()->claim($id); + } + + /** + * Tests behavior of claiming a stage. + */ + public function testClaim(): void { + // Log in as a user so that any stage instances created during the session + // should be able to successfully call ::claim(). + $user_2 = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($user_2); + $creator_stage = $this->createStage(); + + // Ensure that exceptions thrown during ::create() will not lock the stage. + $error = new \Exception('I am going to stop stage creation.'); + TestSubscriber::setException($error, PreCreateEvent::class); + try { + $creator_stage->create(); + $this->fail('Was able to create the stage despite throwing an exception in pre-create.'); + } + catch (\RuntimeException $exception) { + $this->assertSame($error->getMessage(), $exception->getMessage()); + } + + // The stage should be available, and throw if we try to claim it. + $this->assertTrue($creator_stage->isAvailable()); + try { + $creator_stage->claim('any-id-would-fail'); + $this->fail('Was able to claim a stage that has not been created.'); + } + catch (StageException $exception) { + $this->assertSame('Cannot claim the stage because no stage has been created.', $exception->getMessage()); + } + TestSubscriber::setException(NULL, PreCreateEvent::class); + + // Even if we own the stage, we should not be able to claim it with an + // incorrect ID. + $stage_id = $creator_stage->create(); + try { + $this->createStage()->claim('not-correct-id'); + $this->fail('Was able to claim an owned stage with an incorrect ID.'); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because the current lock does not match the stored lock.', $exception->getMessage()); + } + + // A stage that is successfully claimed should be able to call any method + // for its life cycle. + $callbacks = [ + 'require' => [ + ['vendor/lib:0.0.1'], + ], + 'apply' => [], + 'postApply' => [], + 'destroy' => [], + ]; + foreach ($callbacks as $method => $arguments) { + // Create a new stage instance for each method. + $this->createStage()->claim($stage_id)->$method(...$arguments); + } + + // The stage cannot be claimed after it's been destroyed. + try { + $this->createStage()->claim($stage_id); + $this->fail('Was able to claim an owned stage after it was destroyed.'); + } + catch (StageException $exception) { + $this->assertSame('This operation was already canceled.', $exception->getMessage()); + } + + // Create a new stage and then log in as a different user. + $new_stage_id = $this->createStage()->create(); + $user_3 = $this->createUser([], NULL, FALSE, ['uid' => 3]); + $this->setCurrentUser($user_3); + + // Even if they use the correct stage ID, the current user cannot claim a + // stage they didn't create. + try { + $this->createStage()->claim($new_stage_id); + } + catch (StageOwnershipException $exception) { + $this->assertSame('Cannot claim the stage because it is not owned by the current user or session.', $exception->getMessage()); + } + } + + /** + * Tests a stage being destroyed by a user who doesn't own it. + */ + public function testForceDestroy(): void { + $owned = $this->createStage(); + $owned->create(); + + $not_owned = $this->createStage(); + try { + $not_owned->destroy(); + $this->fail("Able to destroy a stage that we don't own."); + } + catch (\LogicException $exception) { + $this->assertSame('Stage must be claimed before performing any operations on it.', $exception->getMessage()); + } + // We should be able to destroy the stage if we ignore ownership. + $not_owned->destroy(TRUE); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StagedDBUpdateValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/StagedDBUpdateValidatorTest.php new file mode 100644 index 00000000000..86473cc988f --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StagedDBUpdateValidatorTest.php @@ -0,0 +1,207 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\Validator\StagedDBUpdateValidator + * @group package_manager + * @internal + */ +class StagedDBUpdateValidatorTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->container->get('theme_installer')->install(['stark']); + $this->assertFalse($this->container->get('module_handler')->moduleExists('views')); + $this->assertFalse($this->container->get('theme_handler')->themeExists('olivero')); + + // Ensure that all the extensions we're testing with have database update + // files in the active directory. + $active_dir = $this->container->get(PathLocator::class)->getProjectRoot(); + + // System and Stark are installed, so they are used to test what happens + // when database updates are detected in installed extensions. Views and + // Olivero are not installed, so they are used to test what happens when + // non-installed extensions have database updates. + $extensions = [ + 'core/modules/system', + 'core/themes/stark', + 'core/modules/views', + 'core/themes/olivero', + ]; + foreach ($extensions as $extension_path) { + $extension_path = $active_dir . '/' . $extension_path; + mkdir($extension_path, 0777, TRUE); + $extension_name = basename($extension_path); + + // Ensure each extension has a .install and a .post_update.php file with + // an empty update function in it. + foreach (['install', 'post_update.php'] as $suffix) { + $function_name = match ($suffix) { + 'install' => $extension_name . '_update_1000', + 'post_update.php' => $extension_name . '_post_update_test', + }; + file_put_contents("$extension_path/$extension_name.$suffix", "<?php\nfunction $function_name() {}"); + } + } + } + + /** + * Data provider for ::testStagedDatabaseUpdates(). + * + * @return array[] + * The test cases. + */ + public static function providerStagedDatabaseUpdate(): array { + $summary = t('Database updates have been detected in the following extensions.'); + + return [ + 'schema update in installed module' => [ + 'core/modules/system', + 'install', + [ + ValidationResult::createWarning([ + t('System'), + ], $summary), + ], + ], + 'post-update in installed module' => [ + 'core/modules/system', + 'post_update.php', + [ + ValidationResult::createWarning([ + t('System'), + ], $summary), + ], + ], + 'schema update in installed theme' => [ + 'core/themes/stark', + 'install', + [ + ValidationResult::createWarning([ + t('Stark'), + ], $summary), + ], + ], + 'post-update in installed theme' => [ + 'core/themes/stark', + 'post_update.php', + [ + ValidationResult::createWarning([ + t('Stark'), + ], $summary), + ], + ], + // The validator should ignore changes in any extensions that aren't + // installed. + 'schema update in non-installed module' => [ + 'core/modules/views', + 'install', + [], + ], + 'post-update in non-installed module' => [ + 'core/modules/views', + 'post_update.php', + [], + ], + 'schema update in non-installed theme' => [ + 'core/themes/olivero', + 'install', + [], + ], + 'post-update in non-installed theme' => [ + 'core/themes/olivero', + 'post_update.php', + [], + ], + ]; + } + + /** + * Tests validation of staged database updates. + * + * @param string $extension_dir + * The directory of the extension that should have database updates, + * relative to the stage directory. + * @param string $file_extension + * The extension of the update file, without the leading period. Must be + * either `install` or `post_update.php`. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerStagedDatabaseUpdate + */ + public function testStagedDatabaseUpdate(string $extension_dir, string $file_extension, array $expected_results): void { + $extension_name = basename($extension_dir); + $relative_file_path = $extension_dir . '/' . $extension_name . '.' . $file_extension; + + $stage = $this->createStage(); + $stage->create(); + // Nothing has been changed in the stage, so ensure the validator doesn't + // detect any changes. + $this->assertStatusCheckResults([], $stage); + + $staged_update_file = $stage->getStageDirectory() . '/' . $relative_file_path; + $this->assertFileIsWritable($staged_update_file); + + // Now add a "real" update function -- either a schema update or a + // post-update, depending on what $file_extension is -- and ensure that the + // validator detects it. + $update_function_name = match ($file_extension) { + 'install' => $extension_name . '_update_1001', + 'post_update.php' => $extension_name . '_post_update_' . $this->randomMachineName(), + }; + file_put_contents($staged_update_file, "function $update_function_name() {}\n", FILE_APPEND); + $this->assertStatusCheckResults($expected_results, $stage); + + // Add a bunch of functions which are named similarly to real schema update + // and post-update functions, but not quite right, to ensure they are + // ignored by the validator. Also throw an anonymous function in there to + // ensure those are ignored as well. + $code = <<<END +<?php +function {$extension_name}_update() { \$foo = function () {}; } +function {$extension_name}_update_string_123() {} +function {$extension_name}_update__123() {} +function ($extension_name}__post_update_test() {} +function ($extension_name}_post_update() {} +END; + file_put_contents($staged_update_file, $code); + $this->assertStatusCheckResults([], $stage); + + // If the update file is deleted from the stage, the validator should not + // detect any database updates. + unlink($staged_update_file); + $this->assertStatusCheckResults([], $stage); + + // If the update file doesn't exist in the active directory, but does exist + // in the stage with a legitimate schema update or post-update function, the + // validator should detect it. + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + unlink($project_root . '/' . $relative_file_path); + file_put_contents($staged_update_file, "<?php\nfunction $update_function_name() {}"); + $this->assertStatusCheckResults($expected_results, $stage); + } + + /** + * Tests that the validator disregards unclaimed stages. + */ + public function testUnclaimedStage(): void { + $stage = $this->createStage(); + $stage->create(); + $this->assertStatusCheckResults([], $stage); + // A new, unclaimed stage should be ignored by the validator. + $this->assertStatusCheckResults([], $this->createStage()); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/StatusCheckTraitTest.php b/core/modules/package_manager/tests/src/Kernel/StatusCheckTraitTest.php new file mode 100644 index 00000000000..7242405264c --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/StatusCheckTraitTest.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\CollectPathsToExcludeEvent; +use Drupal\package_manager\Event\StatusCheckEvent; +use Drupal\package_manager\StatusCheckTrait; +use Drupal\package_manager\ValidationResult; + +/** + * @covers \Drupal\package_manager\StatusCheckTrait + * @group package_manager + * @internal + */ +class StatusCheckTraitTest extends PackageManagerKernelTestBase { + + use StatusCheckTrait; + + /** + * Tests that StatusCheckTrait will collect paths to exclude. + */ + public function testPathsToExcludeCollected(): void { + $this->addEventTestListener(function (CollectPathsToExcludeEvent $event): void { + $event->add('/junk/drawer'); + }, CollectPathsToExcludeEvent::class); + + $status_check_called = FALSE; + $this->addEventTestListener(function (StatusCheckEvent $event) use (&$status_check_called): void { + $this->assertContains('/junk/drawer', $event->excludedPaths->getAll()); + $status_check_called = TRUE; + }, StatusCheckEvent::class); + $this->runStatusCheck($this->createStage(), $this->container->get('event_dispatcher')); + $this->assertTrue($status_check_called); + } + + /** + * Tests that any error will be added to the status check event. + */ + public function testNoErrorIfPathsToExcludeCannotBeCollected(): void { + $e = new \Exception('Not a chance, friend.'); + + $listener = function () use ($e): never { + throw $e; + }; + $this->addEventTestListener($listener, CollectPathsToExcludeEvent::class); + + $excluded_paths_are_null = FALSE; + $listener = function (StatusCheckEvent $event) use (&$excluded_paths_are_null): void { + $excluded_paths_are_null = is_null($event->excludedPaths); + }; + $this->addEventTestListener($listener, StatusCheckEvent::class); + + $this->assertStatusCheckResults([ + ValidationResult::createErrorFromThrowable($e), + ]); + $this->assertTrue($excluded_paths_are_null); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php new file mode 100644 index 00000000000..80bffd51197 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php @@ -0,0 +1,243 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; + +/** + * @coversDefaultClass \Drupal\package_manager\Validator\SupportedReleaseValidator + * @group package_manager + * @internal + */ +class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase { + + use FixtureUtilityTrait; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + (new ActiveFixtureManipulator()) + ->addPackage([ + 'name' => "drupal/dependency", + 'version' => '9.8.0', + 'type' => 'drupal-library', + ]) + ->addPackage([ + 'name' => "drupal/semver_test", + 'version' => '8.1.0', + 'type' => 'drupal-module', + ]) + ->addPackage([ + 'name' => "drupal/aaa_update_test", + 'version' => '2.0.0', + 'type' => 'drupal-module', + ]) + ->addPackage([ + 'name' => "drupal/package_manager_theme", + 'version' => '8.1.0', + 'type' => 'drupal-theme', + ]) + ->addPackage([ + 'name' => "somewhere/a_drupal_module", + 'version' => '8.1.0', + 'type' => 'drupal-module', + ]) + ->addPackage( + [ + 'name' => "drupal/module_no_project", + 'version' => '1.0.0', + 'type' => 'drupal-module', + ], + FALSE, + FALSE, + [ + 'module_no_project.info.yml' => '{name: "Module No Project", type: "module"}', + ], + ) + ->commitChanges(); + } + + /** + * Data provider for testException(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerException(): array { + $release_fixture_folder = __DIR__ . '/../../fixtures/release-history'; + $summary = t('Cannot update because the following project version is not in the list of installable releases.'); + return [ + 'semver, supported update' => [ + [ + 'semver_test' => "$release_fixture_folder/semver_test.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/semver_test", + 'version' => '8.1.1', + 'type' => 'drupal-module', + ], + [], + ], + 'semver, update to unsupported branch' => [ + [ + 'semver_test' => "$release_fixture_folder/semver_test.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/semver_test", + 'version' => '8.2.0', + 'type' => 'drupal-module', + ], + [ + ValidationResult::createError([t('semver_test (drupal/semver_test) 8.2.0')], $summary), + ], + ], + 'legacy, supported update' => [ + [ + 'aaa_update_test' => "$release_fixture_folder/aaa_update_test.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/aaa_update_test", + 'version' => '2.1.0', + 'type' => 'drupal-module', + ], + [], + ], + 'legacy, update to unsupported branch' => [ + [ + 'aaa_update_test' => "$release_fixture_folder/aaa_update_test.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/aaa_update_test", + 'version' => '3.0.0', + 'type' => 'drupal-module', + ], + [ + ValidationResult::createError([t('aaa_update_test (drupal/aaa_update_test) 3.0.0')], $summary), + ], + ], + 'package_manager_test_update(not in active), update to unsupported branch' => [ + [ + 'package_manager_test_update' => "$release_fixture_folder/package_manager_test_update.7.0.1.xml", + ], + FALSE, + [ + 'name' => "drupal/package_manager_test_update", + 'version' => '7.0.1-dev', + 'type' => 'drupal-module', + ], + [ + ValidationResult::createError([t('package_manager_test_update (drupal/package_manager_test_update) 7.0.1-dev')], $summary), + ], + ], + 'package_manager_test_update(not in active), update to supported branch' => [ + [ + 'package_manager_test_update' => "$release_fixture_folder/package_manager_test_update.7.0.1.xml", + ], + FALSE, + [ + 'name' => "drupal/package_manager_test_update", + 'version' => '7.0.1', + 'type' => 'drupal-module', + ], + [], + ], + 'package_manager_theme, supported update' => [ + [ + 'package_manager_theme' => "$release_fixture_folder/package_manager_theme.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/package_manager_theme", + 'version' => '8.1.1', + 'type' => 'drupal-theme', + ], + [], + ], + 'package_manager_theme, update to unsupported branch' => [ + [ + 'package_manager_theme' => "$release_fixture_folder/package_manager_theme.1.1.xml", + ], + TRUE, + [ + 'name' => "drupal/package_manager_theme", + 'version' => '8.2.0', + 'type' => 'drupal-theme', + ], + [ + ValidationResult::createError([t('package_manager_theme (drupal/package_manager_theme) 8.2.0')], $summary), + ], + ], + // For modules that don't start with 'drupal/' will not have update XML + // from drupal.org and so will not be checked by the validator. + // @see \Drupal\package_manager\Validator\SupportedReleaseValidator::checkStagedReleases() + 'updating a module that does not start with drupal/' => [ + [], + TRUE, + [ + 'name' => "somewhere/a_drupal_module", + 'version' => '8.1.1', + 'type' => 'drupal-module', + ], + [], + ], + 'updating a module that does not have project info' => [ + [], + TRUE, + [ + 'name' => "drupal/module_no_project", + 'version' => '1.1.0', + 'type' => 'drupal-module', + ], + [ + ValidationResult::createError([t('Cannot update because the following new or updated Drupal package does not have project information: drupal/module_no_project')]), + ], + ], + ]; + } + + /** + * Tests exceptions when updating to unsupported or insecure releases. + * + * @param array $release_metadata + * Array of paths of the fake release metadata keyed by project name. + * @param bool $project_in_active + * Whether the project is in the active directory or not. + * @param array $package + * The package that will be added or modified. + * @param array $expected_results + * The expected validation results. + * + * @dataProvider providerException + */ + public function testException(array $release_metadata, bool $project_in_active, array $package, array $expected_results): void { + $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml'] + $release_metadata); + + $stage_manipulator = $this->getStageFixtureManipulator(); + if ($project_in_active) { + $stage_manipulator->setVersion($package['name'], $package['version']); + } + else { + $stage_manipulator->addPackage($package); + } + // We always update this module to prove that the validator will skip this + // module as it's of type 'drupal-library'. + // @see \Drupal\package_manager\Validator\SupportedReleaseValidator::checkStagedReleases() + $stage_manipulator->setVersion('drupal/dependency', '9.8.1'); + $this->assertResults($expected_results, PreApplyEvent::class); + // Ensure that any errors arising from invalid project info (which we expect + // in this test) will not fail the test during tear-down. + $this->failureLogger->reset(); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php new file mode 100644 index 00000000000..46d36bfb83c --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php @@ -0,0 +1,203 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Exception\StageEventException; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use PhpTuf\ComposerStager\API\Environment\Service\EnvironmentInterface; +use Prophecy\Argument; + +/** + * @covers \Drupal\package_manager\Validator\SymlinkValidator + * @group package_manager + * @internal + */ +class SymlinkValidatorTest extends PackageManagerKernelTestBase { + + /** + * Tests that relative symlinks within the same package are supported. + */ + public function testSymlinksWithinSamePackage(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + $drush_dir = $project_root . '/vendor/drush/drush'; + mkdir($drush_dir . '/docs', 0777, TRUE); + touch($drush_dir . '/drush_logo-black.png'); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($drush_dir . '/docs'); + symlink('../drush_logo-black.png', 'drush_logo-black.png'); + + // Switch back to the Drupal root to ensure that the check isn't affected + // by which directory we happen to be in. + chdir($this->getDrupalRoot()); + $this->assertStatusCheckResults([]); + } + + /** + * Tests that hard links are not supported. + */ + public function testHardLinks(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + link($project_root . '/composer.json', $project_root . '/composer.link'); + $result = ValidationResult::createError([ + t('The %which directory at %dir contains hard links, which is not supported. The first one is %file.', [ + '%which' => 'active', + '%dir' => $project_root, + '%file' => $project_root . '/composer.json', + ]), + ]); + $this->assertStatusCheckResults([$result]); + } + + /** + * Tests that symlinks with absolute paths are not supported. + */ + public function testAbsoluteSymlinks(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + symlink($project_root . '/composer.json', $project_root . '/composer.link'); + $result = ValidationResult::createError([ + t('The %which directory at %dir contains absolute links, which is not supported. The first one is %file.', [ + '%which' => 'active', + '%dir' => $project_root, + '%file' => $project_root . '/composer.link', + ]), + ]); + $this->assertStatusCheckResults([$result]); + } + + /** + * Tests that relative symlinks cannot point outside the project root. + */ + public function testSymlinkPointingOutsideProjectRoot(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + $parent_dir = dirname($project_root); + touch($parent_dir . '/hello.txt'); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($project_root); + symlink('../hello.txt', 'fail.txt'); + $result = ValidationResult::createError([ + t('The %which directory at %dir contains links that point outside the codebase, which is not supported. The first one is %file.', [ + '%which' => 'active', + '%dir' => $project_root, + '%file' => $project_root . '/fail.txt', + ]), + ]); + $this->assertStatusCheckResults([$result]); + $this->assertResults([$result], PreCreateEvent::class); + } + + /** + * Tests that relative symlinks cannot point outside the stage directory. + */ + public function testSymlinkPointingOutsideStageDirectory(): void { + // The same check should apply to symlinks in the stage directory that + // point outside of it. + $stage = $this->createStage(); + $stage->create(); + $stage->require(['ext-json:*']); + + $stage_dir = $stage->getStageDirectory(); + $parent_dir = dirname($stage_dir); + touch($parent_dir . '/hello.txt'); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($stage_dir); + symlink('../hello.txt', 'fail.txt'); + + $result = ValidationResult::createError([ + t('The %which directory at %dir contains links that point outside the codebase, which is not supported. The first one is %file.', [ + '%which' => 'staging', + '%dir' => $stage_dir, + '%file' => $stage_dir . '/fail.txt', + ]), + ]); + try { + $stage->apply(); + $this->fail('Expected an exception, but none was thrown.'); + } + catch (StageEventException $e) { + $this->assertExpectedResultsFromException([$result], $e); + } + } + + /** + * Tests what happens when there is a symlink to a directory. + */ + public function testSymlinkToDirectory(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + mkdir($project_root . '/modules/custom'); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($project_root . '/modules/custom'); + symlink('../example', 'example_module'); + + // Switch back to the Drupal root to ensure that the check isn't affected + // by which directory we happen to be in. + chdir($this->getDrupalRoot()); + $this->assertStatusCheckResults([]); + } + + /** + * Tests that symlinks are not supported on Windows, even if they're safe. + */ + public function testSymlinksNotAllowedOnWindows(): void { + $environment = $this->prophesize(EnvironmentInterface::class); + $environment->isWindows()->willReturn(TRUE); + $environment->setTimeLimit(Argument::type('int'))->willReturn(TRUE); + $this->container->set(EnvironmentInterface::class, $environment->reveal()); + + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + // Relative symlinks must be made from their actual directory to be + // correctly evaluated. + chdir($project_root); + symlink('composer.json', 'composer.link'); + + $result = ValidationResult::createError([ + t('The %which directory at %dir contains links, which is not supported on Windows. The first one is %file.', [ + '%which' => 'active', + '%dir' => $project_root, + '%file' => $project_root . '/composer.link', + ]), + ]); + $this->assertStatusCheckResults([$result]); + } + + /** + * Tests that unsupported links are excluded if they're under excluded paths. + * + * @depends testAbsoluteSymlinks + * + * @covers \Drupal\package_manager\PathExcluder\GitExcluder + * @covers \Drupal\package_manager\PathExcluder\NodeModulesExcluder + */ + public function testUnsupportedLinkUnderExcludedPath(): void { + $project_root = $this->container->get(PathLocator::class) + ->getProjectRoot(); + + // Create absolute symlinks (which are not supported by Composer Stager) in + // both `node_modules`, which is a regular directory, and `.git`, which is a + // hidden directory. + mkdir($project_root . '/node_modules'); + symlink($project_root . '/composer.json', $project_root . '/node_modules/composer.link'); + symlink($project_root . '/composer.json', $project_root . '/.git/composer.link'); + + $this->assertStatusCheckResults([]); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/TranslatableStringTest.php b/core/modules/package_manager/tests/src/Kernel/TranslatableStringTest.php new file mode 100644 index 00000000000..4f7aaecec15 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/TranslatableStringTest.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\TranslatableStringAdapter; +use Drupal\package_manager\TranslatableStringFactory; +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; + +/** + * @covers \Drupal\package_manager\TranslatableStringFactory + * @covers \Drupal\package_manager\TranslatableStringAdapter + * + * @group package_manager + */ +class TranslatableStringTest extends PackageManagerKernelTestBase { + + /** + * Tests various ways of creating a translatable string. + */ + public function testCreateTranslatableString(): void { + // Ensure that we have properly overridden Composer Stager's factory. + $factory = $this->container->get(TranslatableFactoryInterface::class); + $this->assertInstanceOf(TranslatableStringFactory::class, $factory); + + /** @var \Drupal\package_manager\TranslatableStringAdapter $string */ + $string = $factory->createTranslatableMessage('This string has no parameters.'); + $this->assertInstanceOf(TranslatableStringAdapter::class, $string); + $this->assertEmpty($string->getArguments()); + $this->assertEmpty($string->getOption('context')); + $this->assertSame('This string has no parameters.', (string) $string); + + $parameters = $factory->createTranslationParameters([ + '%name' => 'Slim Shady', + ]); + $string = $factory->createTranslatableMessage('My name is %name.', $parameters, 'outer space'); + $this->assertSame($parameters->getAll(), $string->getArguments()); + $this->assertSame('outer space', $string->getOption('context')); + $this->assertSame('My name is <em class="placeholder">Slim Shady</em>.', (string) $string); + } + +} diff --git a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php new file mode 100644 index 00000000000..c18baf6b445 --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php @@ -0,0 +1,265 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Symfony\Component\Filesystem\Filesystem; + +/** + * Unit tests the file system permissions validator. + * + * This validator is tested functionally in Automatic Updates' build tests, + * since those give us control over the file system permissions. + * + * @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() + * + * @covers \Drupal\package_manager\Validator\WritableFileSystemValidator + * @group package_manager + * @internal + */ +class WritableFileSystemValidatorTest extends PackageManagerKernelTestBase { + + /** + * Data provider for testWritable(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerWritable(): array { + // @see \Drupal\Tests\package_manager\Traits\ValidationTestTrait::resolvePlaceholdersInArrayValuesWithRealPaths() + $drupal_root_error = t('The Drupal directory "<PROJECT_ROOT>/web" is not writable.'); + $vendor_error = t('The vendor directory "<VENDOR_DIR>" is not writable.'); + $project_root_error = t('The project root directory "<PROJECT_ROOT>" is not writable.'); + $summary = t('The file system is not writable.'); + $writable_permission = 0777; + $non_writable_permission = 0550; + + return [ + 'root and vendor are writable, nested web root' => [ + $writable_permission, + $writable_permission, + $writable_permission, + 'web', + [], + ], + 'root writable, vendor not writable, nested web root' => [ + $writable_permission, + $writable_permission, + $non_writable_permission, + 'web', + [ + ValidationResult::createError([$vendor_error], $summary), + ], + ], + 'root not writable, vendor writable, nested web root' => [ + $non_writable_permission, + $non_writable_permission, + $writable_permission, + 'web', + [ + ValidationResult::createError([$drupal_root_error, $project_root_error], $summary), + ], + ], + 'nothing writable, nested web root' => [ + $non_writable_permission, + $non_writable_permission, + $non_writable_permission, + 'web', + [ + ValidationResult::createError([$drupal_root_error, $project_root_error, $vendor_error], $summary), + ], + ], + 'root and vendor are writable, non-nested web root' => [ + $writable_permission, + $writable_permission, + $writable_permission, + '', + [], + ], + 'root writable, vendor not writable, non-nested web root' => [ + $writable_permission, + $writable_permission, + $non_writable_permission, + '', + [ + ValidationResult::createError([$vendor_error], $summary), + ], + ], + 'root not writable, vendor writable, non-nested web root' => [ + $non_writable_permission, + $non_writable_permission, + $writable_permission, + '', + [ + ValidationResult::createError([$project_root_error], $summary), + ], + ], + 'nothing writable, non-nested web root' => [ + $non_writable_permission, + $non_writable_permission, + $non_writable_permission, + '', + [ + ValidationResult::createError([$project_root_error, $vendor_error], $summary), + ], + ], + ]; + } + + /** + * Tests the file system permissions validator. + * + * @param int $root_permissions + * The file permissions for the root folder. + * @param int $webroot_permissions + * The file permissions for the web root folder. + * @param int $vendor_permissions + * The file permissions for the vendor folder. + * @param string $webroot_relative_directory + * The web root path, relative to the project root, or an empty string if + * the web root and project root are the same. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerWritable + */ + public function testWritable(int $root_permissions, int $webroot_permissions, int $vendor_permissions, string $webroot_relative_directory, array $expected_results): void { + $this->setUpPermissions($root_permissions, $webroot_permissions, $vendor_permissions, $webroot_relative_directory); + + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + + /** + * Tests the file system permissions validator during pre-apply. + * + * @param int $root_permissions + * The file permissions for the root folder. + * @param int $webroot_permissions + * The file permissions for the web root folder. + * @param int $vendor_permissions + * The file permissions for the vendor folder. + * @param string $webroot_relative_directory + * The web root path, relative to the project root, or an empty string if + * the web root and project root are the same. + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * + * @dataProvider providerWritable + */ + public function testWritableDuringPreApply(int $root_permissions, int $webroot_permissions, int $vendor_permissions, string $webroot_relative_directory, array $expected_results): void { + $this->addEventTestListener( + function () use ($webroot_permissions, $root_permissions, $vendor_permissions, $webroot_relative_directory): void { + $this->setUpPermissions($root_permissions, $webroot_permissions, $vendor_permissions, $webroot_relative_directory); + + // During pre-apply we don't care whether the staging root is writable. + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + $this->assertTrue(chmod($path_locator->getStagingRoot(), 0550)); + }, + ); + + $this->assertResults($expected_results, PreApplyEvent::class); + } + + /** + * Sets the permissions of the test project's directories. + * + * @param int $root_permissions + * The permissions for the project root. + * @param int $web_root_permissions + * The permissions for the web root. + * @param int $vendor_permissions + * The permissions for the vendor directory. + * @param string $relative_web_root + * The web root path, relative to the project root, or an empty string if + * the web root and project root are the same. + */ + private function setUpPermissions(int $root_permissions, int $web_root_permissions, int $vendor_permissions, string $relative_web_root): void { + /** @var \Drupal\package_manager_bypass\MockPathLocator $path_locator */ + $path_locator = $this->container->get(PathLocator::class); + + $project_root = $web_root = $path_locator->getProjectRoot(); + $vendor_dir = $path_locator->getVendorDirectory(); + // Create the web root directory, if necessary. + if (!empty($relative_web_root)) { + $web_root .= '/' . $relative_web_root; + mkdir($web_root); + } + $path_locator->setPaths($project_root, $vendor_dir, $relative_web_root, $path_locator->getStagingRoot()); + + // We need to set the vendor directory and web root permissions first + // because they may be located inside the project root. + $this->assertTrue(chmod($vendor_dir, $vendor_permissions)); + if ($project_root !== $web_root) { + $this->assertTrue(chmod($web_root, $web_root_permissions)); + } + $this->assertTrue(chmod($project_root, $root_permissions)); + } + + /** + * Data provider for ::testStagingRootPermissions(). + * + * @return mixed[][] + * The test cases. + */ + public static function providerStagingRootPermissions(): array { + $writable_permission = 0777; + $non_writable_permission = 0550; + $summary = t('The file system is not writable.'); + return [ + 'writable stage root exists' => [ + $writable_permission, + [], + FALSE, + ], + 'write-protected stage root exists' => [ + $non_writable_permission, + [ + ValidationResult::createError([t('The stage root directory "<STAGE_ROOT>" is not writable.')], $summary), + ], + FALSE, + ], + 'stage root directory does not exist, parent directory not writable' => [ + $non_writable_permission, + [ + ValidationResult::createError([t('The stage root directory will not able to be created at "<STAGE_ROOT_PARENT>".')], $summary), + ], + TRUE, + ], + ]; + } + + /** + * Tests that the stage root's permissions are validated. + * + * @param int $permissions + * The file permissions to apply to the stage root directory, or its parent + * directory, depending on the value of $delete_staging_root. + * @param array $expected_results + * The expected validation results. + * @param bool $delete_staging_root + * Whether the stage root directory will exist at all. + * + * @dataProvider providerStagingRootPermissions + */ + public function testStagingRootPermissions(int $permissions, array $expected_results, bool $delete_staging_root): void { + $dir = $this->container->get(PathLocator::class) + ->getStagingRoot(); + + if ($delete_staging_root) { + $fs = new Filesystem(); + $fs->remove($dir); + $dir = dirname($dir); + } + $this->assertTrue(chmod($dir, $permissions)); + $this->assertStatusCheckResults($expected_results); + $this->assertResults($expected_results, PreCreateEvent::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/AssertPreconditionsTrait.php b/core/modules/package_manager/tests/src/Traits/AssertPreconditionsTrait.php new file mode 100644 index 00000000000..c55bfebf56f --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/AssertPreconditionsTrait.php @@ -0,0 +1,105 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Composer\Autoload\ClassLoader; + +/** + * Asserts preconditions for tests to function properly. + */ +trait AssertPreconditionsTrait { + + /** + * Invokes the test preconditions assertion before the first test is run. + * + * "Use" this trait on any Package Manager test class that directly extends a + * Core test class, i.e., any class that does NOT extend a test class in a + * Package Manager test namespace. If that class implements this method, too, + * be sure to call this first thing in it. + */ + public static function setUpBeforeClass(): void { + parent::setUpBeforeClass(); + static::failIfUnmetPreConditions('before'); + } + + /** + * Invokes the test preconditions assertion after each test run. + * + * This ensures that no test method leaves behind violations of test + * preconditions. This makes it trivial to discover broken tests. + */ + protected function tearDown(): void { + parent::tearDown(); + static::failIfUnmetPreConditions('after'); + } + + /** + * Asserts universal test preconditions before any setup is done. + * + * If these preconditions aren't met, automated tests will absolutely fail + * needlessly with misleading errors. In that case, there's no reason to even + * begin. + * + * Ordinarily, these preconditions would be asserted in + * ::assertPreConditions(), which PHPUnit provides for exactly this use case. + * Unfortunately, that method doesn't run until after ::setUp(), so our (many) + * tests with expensive, time-consuming setup routines wouldn't actually fail + * very early. + * + * @param string $when + * Either 'before' (before any test methods run) or 'after' (after any test + * method finishes). + * + * @see \PHPUnit\Framework\TestCase::assertPreConditions() + * @see \PHPUnit\Framework\TestCase::setUpBeforeClass() + * @see self::setupBeforeClass() + * @see self::tearDown() + */ + protected static function failIfUnmetPreConditions(string $when): void { + assert(in_array($when, ['before', 'after'], TRUE)); + static::assertNoFailureMarker($when); + } + + /** + * Asserts that there is no failure marker present. + * + * @param string $when + * Either 'before' (before any test methods run) or 'after' (after any test + * method finishes). + * + * @see \Drupal\package_manager\FailureMarker + */ + private static function assertNoFailureMarker(string $when): void { + // If the failure marker exists, it will be in the project root. The project + // root is defined as the directory containing the `vendor` directory. + // @see \Drupal\package_manager\FailureMarker::getPath() + $failure_marker = static::getProjectRoot() . '/PACKAGE_MANAGER_FAILURE.yml'; + if (file_exists($failure_marker)) { + $suffix = $when === 'before' + ? 'Remove it to continue.' + : 'This test method created this marker but failed to clean up after itself.'; + static::fail("The failure marker '$failure_marker' is present in the project. $suffix"); + } + } + + /** + * Returns the absolute path of the project root. + * + * @return string + * The absolute path of the project root. + * + * @see \Drupal\package_manager\PathLocator::getProjectRoot() + */ + private static function getProjectRoot(): string { + // This is tricky, because this method has to be static (since + // ::setUpBeforeClass is), so it can't just get the container from an + // instance member. + // Use reflection to extract the vendor directory from the class loader. + $class_loaders = ClassLoader::getRegisteredLoaders(); + $vendor_directory = key($class_loaders); + return dirname($vendor_directory); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/ComposerInstallersTrait.php b/core/modules/package_manager/tests/src/Traits/ComposerInstallersTrait.php new file mode 100644 index 00000000000..b946e987af9 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/ComposerInstallersTrait.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Composer\InstalledVersions; +use Drupal\fixture_manipulator\FixtureManipulator; +use Drupal\package_manager\ComposerInspector; +use Symfony\Component\Process\Process; + +/** + * A utility for kernel tests that need to use 'composer/installers'. + * + * @internal + */ +trait ComposerInstallersTrait { + + /** + * Installs the composer/installers package. + * + * @param string $dir + * The fixture directory to install into. + */ + private function installComposerInstallers(string $dir): void { + $package_name = 'composer/installers'; + $this->assertTrue(InstalledVersions::isInstalled($package_name)); + + $repository = json_encode([ + 'type' => 'path', + 'url' => InstalledVersions::getInstallPath($package_name), + 'options' => [ + 'symlink' => FALSE, + 'versions' => [ + // Explicitly state the version contained by this path repository, + // otherwise Composer will infer the version based on the git clone or + // fall back to `dev-master`. + // @see https://getcomposer.org/doc/05-repositories.md#path + 'composer/installers' => InstalledVersions::getVersion($package_name), + ], + ], + ], JSON_UNESCAPED_SLASHES); + $working_dir_option = "--working-dir=$dir"; + (new Process(['composer', 'config', 'repo.composer-installers-real', $repository, $working_dir_option]))->mustRun(); + (new FixtureManipulator()) + ->addConfig(['allow-plugins.composer/installers' => TRUE]) + ->commitChanges($dir); + (new Process(['composer', 'require', 'composer/installers:@dev', $working_dir_option]))->mustRun(); + + // Use the default installer paths for Drupal core and extensions. + $this->setInstallerPaths([], $dir); + } + + /** + * Sets the installer paths config. + * + * @param array $installer_paths + * The installed paths. + * @param string $directory + * The fixture directory. + */ + private function setInstallerPaths(array $installer_paths, string $directory): void { + // Respect any existing installer paths. + $extra = $this->container->get(ComposerInspector::class) + ->getConfig('extra', $directory . '/composer.json'); + $existing_installer_paths = json_decode($extra, TRUE, flags: JSON_THROW_ON_ERROR)['installer-paths'] ?? []; + + (new FixtureManipulator()) + ->addConfig([ + 'extra.installer-paths' => $installer_paths + $existing_installer_paths, + ]) + ->commitChanges($directory); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/ComposerStagerTestTrait.php b/core/modules/package_manager/tests/src/Traits/ComposerStagerTestTrait.php new file mode 100644 index 00000000000..9f76afd9058 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/ComposerStagerTestTrait.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface; +use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface; + +/** + * Contains helper methods for testing Composer Stager interactions. + * + * @internal + * + * @property \Symfony\Component\DependencyInjection\ContainerInterface $container + */ +trait ComposerStagerTestTrait { + + /** + * Creates a Composer Stager translatable message. + * + * @param string $message + * A message containing optional placeholders corresponding to parameters (next). Example: + * ```php + * $message = 'Hello, %first_name %last_name.'; + * ``` + * @param \PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface|null $parameters + * Translation parameters. + * @param string|null $domain + * An arbitrary domain for grouping translations or null to use the default. See + * {@see \PhpTuf\ComposerStager\API\Translation\Service\DomainOptionsInterface}. + * + * @return \PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface + * A message that can be translated by Composer Stager. + */ + protected function createComposeStagerMessage( + string $message, + ?TranslationParametersInterface $parameters = NULL, + ?string $domain = NULL, + ): TranslatableInterface { + /** @var \PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface $translatable_factory */ + $translatable_factory = $this->container->get(TranslatableFactoryInterface::class); + + return $translatable_factory->createTranslatableMessage($message, $parameters, $domain); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/FixtureManipulatorTrait.php b/core/modules/package_manager/tests/src/Traits/FixtureManipulatorTrait.php new file mode 100644 index 00000000000..4d6939aa875 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/FixtureManipulatorTrait.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Drupal\fixture_manipulator\StageFixtureManipulator; + +/** + * A trait for common fixture manipulator functions. + */ +trait FixtureManipulatorTrait { + + /** + * Gets the stage fixture manipulator service. + * + * @return \Drupal\fixture_manipulator\StageFixtureManipulator|object|null + * The stage fixture manipulator service. + */ + protected function getStageFixtureManipulator() { + return $this->container->get(StageFixtureManipulator::class); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php b/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php new file mode 100644 index 00000000000..e2fc313b543 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Finder\Iterator\RecursiveDirectoryIterator; + +/** + * A utility for all things fixtures. + * + * @internal + */ +trait FixtureUtilityTrait { + + /** + * Mirrors a fixture directory to the given path. + * + * Files not in the source fixture directory will not be deleted from + * destination directory. After copying the files to the destination directory + * the files and folders will be converted so that can be used in the tests. + * The conversion includes: + * - Renaming '_git' directories to '.git' + * - Renaming files ending in '.info.yml.hide' to remove '.hide'. + * + * @param string $source_path + * The source path. + * @param string $destination_path + * The path to which the fixture files should be mirrored. + */ + protected static function copyFixtureFilesTo(string $source_path, string $destination_path): void { + (new Filesystem())->mirror($source_path, $destination_path, NULL, [ + 'override' => TRUE, + 'delete' => FALSE, + ]); + static::renameInfoYmlFiles($destination_path); + static::renameGitDirectories($destination_path); + } + + /** + * Renames all files that end with .info.yml.hide. + * + * @param string $dir + * The directory to be iterated through. + */ + protected static function renameInfoYmlFiles(string $dir): void { + // Construct the iterator. + $it = new RecursiveDirectoryIterator($dir, \RecursiveIteratorIterator::SELF_FIRST); + + // Loop through files and rename them. + foreach (new \RecursiveIteratorIterator($it) as $file) { + if ($file->getExtension() == 'hide') { + rename($file->getPathname(), $dir . DIRECTORY_SEPARATOR . + $file->getRelativePath() . DIRECTORY_SEPARATOR . str_replace(".hide", "", $file->getFilename())); + } + } + } + + /** + * Renames _git directories to .git. + * + * @param string $dir + * The directory to be iterated through. + */ + private static function renameGitDirectories(string $dir): void { + $iter = new \RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + \RecursiveIteratorIterator::CATCH_GET_CHILD + ); + /** @var \Symfony\Component\Finder\SplFileInfo $file */ + foreach ($iter as $file) { + if ($file->isDir() && $file->getFilename() === '_git' && $file->getRelativePathname()) { + rename( + $file->getPathname(), + $file->getPath() . DIRECTORY_SEPARATOR . '.git' + ); + } + } + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/InstalledPackagesListTrait.php b/core/modules/package_manager/tests/src/Traits/InstalledPackagesListTrait.php new file mode 100644 index 00000000000..223fe719808 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/InstalledPackagesListTrait.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; + +/** + * A trait for comparing InstalledPackagesList objects. + * + * @internal + * This is an internal part of Package Manager and may be changed or removed + * at any time without warning. External code should not interact with this + * class. + */ +trait InstalledPackagesListTrait { + + /** + * Asserts that 2 installed package lists are equal. + * + * @param \Drupal\package_manager\InstalledPackagesList $expected_list + * The expected list. + * @param \Drupal\package_manager\InstalledPackagesList $actual_list + * The actual list. + */ + private function assertPackageListsEqual(InstalledPackagesList $expected_list, InstalledPackagesList $actual_list): void { + $expected_array = $expected_list->getArrayCopy(); + $actual_array = $actual_list->getArrayCopy(); + ksort($expected_array); + ksort($actual_array); + $this->assertSame(array_keys($expected_array), array_keys($actual_array)); + foreach ($expected_list as $package_name => $expected_package) { + $this->assertInstanceOf(InstalledPackage::class, $expected_package); + $actual_package = $actual_list[$package_name]; + $this->assertInstanceOf(InstalledPackage::class, $actual_package); + $this->assertSame($expected_package->name, $actual_package->name); + $this->assertSame($expected_package->version, $actual_package->version); + $this->assertSame($expected_package->path, $actual_package->path); + $this->assertSame($expected_package->type, $actual_package->type); + $this->assertSame($expected_package->getProjectName(), $actual_package->getProjectName()); + } + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php b/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php new file mode 100644 index 00000000000..797a84f8a4d --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Core\StagerInterface; + +/** + * Common functions for testing using the package_manager_bypass module. + * + * @internal + */ +trait PackageManagerBypassTestTrait { + + /** + * Asserts the number of times an update was staged. + * + * @param int $attempted_times + * The expected number of times an update was staged. + */ + protected function assertUpdateStagedTimes(int $attempted_times): void { + /** @var \Drupal\package_manager_bypass\LoggingBeginner $beginner */ + $beginner = $this->container->get(BeginnerInterface::class); + $this->assertCount($attempted_times, $beginner->getInvocationArguments()); + + /** @var \Drupal\package_manager_bypass\NoOpStager $stager */ + $stager = $this->container->get(StagerInterface::class); + // If an update was attempted, then there will be at least two calls to the + // stager: one to change the runtime constraints in composer.json, and + // another to actually update the installed dependencies. If any dev + // packages (like `drupal/core-dev`) are installed, there may also be an + // additional call to change the dev constraints. + $this->assertGreaterThanOrEqual($attempted_times * 2, count($stager->getInvocationArguments())); + + /** @var \Drupal\package_manager_bypass\LoggingCommitter $committer */ + $committer = $this->container->get(CommitterInterface::class); + $this->assertEmpty($committer->getInvocationArguments()); + } + +} diff --git a/core/modules/package_manager/tests/src/Traits/ValidationTestTrait.php b/core/modules/package_manager/tests/src/Traits/ValidationTestTrait.php new file mode 100644 index 00000000000..75f41f1a156 --- /dev/null +++ b/core/modules/package_manager/tests/src/Traits/ValidationTestTrait.php @@ -0,0 +1,130 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Traits; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\ValidationResult; +use Drupal\Tests\BrowserTestBase; +use Drupal\Tests\UnitTestCase; + +/** + * Contains helpful methods for testing stage validators. + * + * @internal + */ +trait ValidationTestTrait { + + /** + * Asserts two validation result sets are equal. + * + * This assertion is sensitive to the order of results. For example, + * ['a', 'b'] is not equal to ['b', 'a']. + * + * @param \Drupal\package_manager\ValidationResult[] $expected_results + * The expected validation results. + * @param \Drupal\package_manager\ValidationResult[] $actual_results + * The actual validation results. + * @param \Drupal\package_manager\PathLocator|null $path_locator + * (optional) The path locator (when this trait is used in unit tests). + * @param string|null $stage_dir + * (optional) The stage directory. + */ + protected function assertValidationResultsEqual(array $expected_results, array $actual_results, ?PathLocator $path_locator = NULL, ?string $stage_dir = NULL): void { + if ($path_locator) { + assert(is_a(get_called_class(), UnitTestCase::class, TRUE)); + } + $expected_results = array_map( + function (array $result) use ($path_locator, $stage_dir): array { + $result['messages'] = $this->resolvePlaceholdersInArrayValuesWithRealPaths($result['messages'], $path_locator, $stage_dir); + return $result; + }, + $this->getValidationResultsAsArray($expected_results) + ); + $actual_results = $this->getValidationResultsAsArray($actual_results); + + self::assertSame($expected_results, $actual_results); + } + + /** + * Resolves <PROJECT_ROOT>, <VENDOR_DIR>, <STAGE_ROOT>, <STAGE_ROOT_PARENT>. + * + * @param array $subject + * An array with arbitrary keys, and values potentially containing the + * placeholders <PROJECT_ROOT>, <VENDOR_DIR>, <STAGE_ROOT>, or + * <STAGE_ROOT_PARENT>. <STAGE_DIR> is the placeholder for $stage_dir, if + * passed. + * @param \Drupal\package_manager\PathLocator|null $path_locator + * (optional) The path locator (when this trait is used in unit tests). + * @param string|null $stage_dir + * (optional) The stage directory. + * + * @return array + * The same array, with unchanged keys, and with the placeholders resolved. + */ + protected function resolvePlaceholdersInArrayValuesWithRealPaths(array $subject, ?PathLocator $path_locator = NULL, ?string $stage_dir = NULL): array { + if (!$path_locator) { + // Only kernel and browser tests have $this->container. + assert($this instanceof KernelTestBase || $this instanceof BrowserTestBase); + $path_locator = $this->container->get(PathLocator::class); + } + $subject = str_replace( + ['<PROJECT_ROOT>', '<VENDOR_DIR>', '<STAGE_ROOT>', '<STAGE_ROOT_PARENT>'], + [$path_locator->getProjectRoot(), $path_locator->getVendorDirectory(), $path_locator->getStagingRoot(), dirname($path_locator->getStagingRoot())], + $subject + ); + if ($stage_dir) { + $subject = str_replace(['<STAGE_DIR>'], [$stage_dir], $subject); + } + foreach ($subject as $message) { + if (str_contains($message, '<STAGE_DIR>')) { + throw new \LogicException("No stage directory passed to replace '<STAGE_DIR>' in message '$message'"); + } + } + return $subject; + } + + /** + * Gets an array representation of validation results for easy comparison. + * + * @param \Drupal\package_manager\ValidationResult[] $results + * An array of validation results. + * + * @return array + * An array of validation results details: + * - severity: (int) The severity code. + * - messages: (array) An array of strings. + * - summary: (string|null) A summary string if there is one or NULL if not. + */ + protected function getValidationResultsAsArray(array $results): array { + $string_translation_stub = NULL; + if (is_a(get_called_class(), UnitTestCase::class, TRUE)) { + assert($this instanceof UnitTestCase); + $string_translation_stub = $this->getStringTranslationStub(); + } + return array_values(array_map(static function (ValidationResult $result) use ($string_translation_stub) { + $messages = array_map(static function ($message) use ($string_translation_stub): string { + // Support data providers in unit tests using TranslatableMarkup. + if ($message instanceof TranslatableMarkup && is_a(get_called_class(), UnitTestCase::class, TRUE)) { + $message = new TranslatableMarkup($message->getUntranslatedString(), $message->getArguments(), $message->getOptions(), $string_translation_stub); + } + return (string) $message; + }, $result->messages); + + $summary = $result->summary; + if ($summary !== NULL) { + $summary = (string) $result->summary; + } + + return [ + 'severity' => $result->severity, + 'messages' => $messages, + 'summary' => $summary, + ]; + }, $results)); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/ExecutableFinderTest.php b/core/modules/package_manager/tests/src/Unit/ExecutableFinderTest.php new file mode 100644 index 00000000000..811d7547e04 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/ExecutableFinderTest.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\ExecutableFinder; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Finder\Service\ExecutableFinderInterface; + +/** + * @covers \Drupal\package_manager\ExecutableFinder + * @group package_manager + * @internal + */ +class ExecutableFinderTest extends UnitTestCase { + + /** + * Tests that the executable finder looks for paths in configuration. + */ + public function testCheckConfigurationForExecutablePath(): void { + $config_factory = $this->getConfigFactoryStub([ + 'package_manager.settings' => [ + 'executables' => [ + 'composer' => '/path/to/composer', + ], + ], + ]); + + $decorated = $this->prophesize(ExecutableFinderInterface::class); + $decorated->find('composer')->shouldNotBeCalled(); + $decorated->find('rsync')->shouldBeCalled(); + + $finder = new ExecutableFinder($decorated->reveal(), $config_factory); + $this->assertSame('/path/to/composer', $finder->find('composer')); + $finder->find('rsync'); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/InstalledPackageTest.php b/core/modules/package_manager/tests/src/Unit/InstalledPackageTest.php new file mode 100644 index 00000000000..5cd31fc3e03 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/InstalledPackageTest.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\InstalledPackage; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackage + * + * @group package_manager + */ +class InstalledPackageTest extends UnitTestCase { + + /** + * @covers ::createFromArray + * + * @depends testMetapackageWithAPath + */ + public function testPathResolution(): void { + // Metapackages must be created without a path. + $package = InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'metapackage', + 'version' => '1.0.0', + 'path' => NULL, + ]); + $this->assertNull($package->path); + + // Paths should be converted to real paths. + $package = InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'library', + 'version' => '1.0.0', + 'path' => __DIR__ . '/..', + ]); + $this->assertSame(realpath(__DIR__ . '/..'), $package->path); + + // If we provide a path that cannot be resolved to a real path, it should + // raise an error. + $this->expectException(\TypeError::class); + InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'library', + 'version' => '1.0.0', + 'path' => $this->getRandomGenerator()->string(), + ]); + } + + /** + * @covers ::createFromArray + */ + public function testMetapackageWithAPath(): void { + $this->expectException(\AssertionError::class); + $this->expectExceptionMessage('Metapackage install path must be NULL.'); + + InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'metapackage', + 'version' => '1.0.0', + 'path' => __DIR__, + ]); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/InstalledPackagesListTest.php b/core/modules/package_manager/tests/src/Unit/InstalledPackagesListTest.php new file mode 100644 index 00000000000..ca8c4021222 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/InstalledPackagesListTest.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackagesList + * + * @group package_manager + */ +class InstalledPackagesListTest extends UnitTestCase { + + /** + * @covers ::offsetSet + * @covers ::offsetUnset + * @covers ::append + * @covers ::exchangeArray + * + * @testWith ["offsetSet", ["new", "thing"]] + * ["offsetUnset", ["existing"]] + * ["append", ["new thing"]] + * ["exchangeArray", [{"evil": "twin"}]] + */ + public function testImmutability(string $method, array $arguments): void { + $list = new InstalledPackagesList(['existing' => 'thing']); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Installed package lists cannot be modified.'); + $list->$method(...$arguments); + } + + /** + * @covers ::getPackagesNotIn + * @covers ::getPackagesWithDifferentVersionsIn + */ + public function testPackageComparison(): void { + $active = new InstalledPackagesList([ + 'drupal/existing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/existing', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + 'drupal/updated' => InstalledPackage::createFromArray([ + 'name' => 'drupal/updated', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + 'drupal/removed' => InstalledPackage::createFromArray([ + 'name' => 'drupal/removed', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + ]); + $staged = new InstalledPackagesList([ + 'drupal/existing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/existing', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + 'drupal/updated' => InstalledPackage::createFromArray([ + 'name' => 'drupal/updated', + 'version' => '1.1.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + 'drupal/added' => InstalledPackage::createFromArray([ + 'name' => 'drupal/added', + 'version' => '1.0.0', + 'path' => __DIR__, + 'type' => 'drupal-module', + ]), + ]); + + $added = $staged->getPackagesNotIn($active)->getArrayCopy(); + $this->assertSame(['drupal/added'], array_keys($added)); + + $removed = $active->getPackagesNotIn($staged)->getArrayCopy(); + $this->assertSame(['drupal/removed'], array_keys($removed)); + + $updated = $active->getPackagesWithDifferentVersionsIn($staged)->getArrayCopy(); + $this->assertSame(['drupal/updated'], array_keys($updated)); + } + + /** + * @covers ::getCorePackages + */ + public function testCorePackages(): void { + $data = [ + 'drupal/core' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core', + 'version' => \Drupal::VERSION, + 'type' => 'drupal-core', + 'path' => __DIR__, + ]), + 'drupal/core-dev' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-dev', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]), + 'drupal/core-dev-pinned' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-dev-pinned', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]), + 'drupal/core-composer-scaffold' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-composer-scaffold', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => __DIR__, + ]), + 'drupal/core-project-message' => [ + 'name' => 'drupal/core-project-message', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => __DIR__, + ], + 'drupal/core-vendor-hardening' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-vendor-hardening', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => __DIR__, + ]), + 'drupal/not-core' => InstalledPackage::createFromArray([ + 'name' => 'drupal/not-core', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => __DIR__, + ]), + ]; + + $list = new InstalledPackagesList($data); + $this->assertArrayNotHasKey('drupal/not-core', $list->getCorePackages()); + + // Tests that we don't get core packages intended for development when + // include_dev is set to FALSE. + $core_packages_no_dev = $list->getCorePackages(FALSE); + $this->assertArrayNotHasKey('drupal/core-dev', $core_packages_no_dev); + $this->assertArrayNotHasKey('drupal/core-dev-pinned', $core_packages_no_dev); + // We still get other packages as intended. + $this->assertArrayHasKey('drupal/core', $core_packages_no_dev); + + // If drupal/core-recommended is in the list, it should supersede + // drupal/core. + $this->assertArrayHasKey('drupal/core', $list->getCorePackages()); + $data['drupal/core-recommended'] = InstalledPackage::createFromArray([ + 'name' => 'drupal/core-recommended', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]); + $list = new InstalledPackagesList($data); + $this->assertArrayNotHasKey('drupal/core', $list->getCorePackages()); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/LoggingBeginnerTest.php b/core/modules/package_manager/tests/src/Unit/LoggingBeginnerTest.php new file mode 100644 index 00000000000..af84befb7da --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/LoggingBeginnerTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\package_manager\FileProcessOutputCallback; +use Drupal\package_manager\LoggingBeginner; +use Drupal\package_manager\ProcessOutputCallback; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Core\BeginnerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; + +/** + * @covers \Drupal\package_manager\LoggingBeginner + * @group package_manager + */ +class LoggingBeginnerTest extends UnitTestCase { + + public function testDecoratedBeginnerIsCalled(): void { + $decorated = $this->createMock(BeginnerInterface::class); + + $activeDir = $this->createMock(PathInterface::class); + $stagingDir = $this->createMock(PathInterface::class); + $stagingDir->expects($this->any()) + ->method('absolute') + ->willReturn('staging-dir'); + + $decorated->expects($this->once()) + ->method('begin') + ->with( + $activeDir, + $stagingDir, + NULL, + $this->isInstanceOf(FileProcessOutputCallback::class), + ); + + $config_factory = $this->getConfigFactoryStub([ + 'package_manager.settings' => ['log' => 'php://memory'], + ]); + $time = $this->createMock(TimeInterface::class); + $time->expects($this->atLeast(2)) + ->method('getCurrentMicroTime') + ->willReturnOnConsecutiveCalls(1, 2.5); + + $callback = new ProcessOutputCallback(); + + (new LoggingBeginner($decorated, $config_factory, $time)) + ->begin($activeDir, $stagingDir, callback: $callback); + + $this->assertSame([ + "### Beginning in staging-dir\n", + "### Finished in 1.500 seconds\n", + ], $callback->getOutput()); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/LoggingCommitterTest.php b/core/modules/package_manager/tests/src/Unit/LoggingCommitterTest.php new file mode 100644 index 00000000000..3d6ab55de0c --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/LoggingCommitterTest.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\package_manager\FileProcessOutputCallback; +use Drupal\package_manager\LoggingCommitter; +use Drupal\package_manager\ProcessOutputCallback; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Core\CommitterInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; + +/** + * @covers \Drupal\package_manager\LoggingCommitter + * @group package_manager + */ +class LoggingCommitterTest extends UnitTestCase { + + public function testDecoratedCommitterIsCalled(): void { + $decorated = $this->createMock(CommitterInterface::class); + + $stagingDir = $this->createMock(PathInterface::class); + $stagingDir->expects($this->any()) + ->method('absolute') + ->willReturn('staging-dir'); + $activeDir = $this->createMock(PathInterface::class); + $activeDir->expects($this->any()) + ->method('absolute') + ->willReturn('active-dir'); + + $decorated->expects($this->once()) + ->method('commit') + ->with( + $stagingDir, + $activeDir, + NULL, + $this->isInstanceOf(FileProcessOutputCallback::class), + ); + + $config_factory = $this->getConfigFactoryStub([ + 'package_manager.settings' => ['log' => 'php://memory'], + ]); + $time = $this->createMock(TimeInterface::class); + $time->expects($this->atLeast(2)) + ->method('getCurrentMicroTime') + ->willReturnOnConsecutiveCalls(1, 2.5); + + $callback = new ProcessOutputCallback(); + + (new LoggingCommitter($decorated, $config_factory, $time)) + ->commit($stagingDir, $activeDir, callback: $callback); + + $this->assertSame([ + "### Committing changes from staging-dir to active-dir\n", + "### Finished in 1.500 seconds\n", + ], $callback->getOutput()); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/LoggingStagerTest.php b/core/modules/package_manager/tests/src/Unit/LoggingStagerTest.php new file mode 100644 index 00000000000..f21577c348d --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/LoggingStagerTest.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\FileProcessOutputCallback; +use Drupal\package_manager\LoggingStager; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Core\StagerInterface; +use PhpTuf\ComposerStager\API\Path\Value\PathInterface; +use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; + +/** + * @covers \Drupal\package_manager\LoggingStager + * @group package_manager + */ +class LoggingStagerTest extends UnitTestCase { + + public function testDecoratedStagerIsCalled(): void { + $decorated = $this->createMock(StagerInterface::class); + + $activeDir = $this->createMock(PathInterface::class); + $stagingDir = $this->createMock(PathInterface::class); + $stagingDir->expects($this->any()) + ->method('absolute') + ->willReturn('staging-dir'); + + $original_callback = $this->createMock(OutputCallbackInterface::class); + $original_callback->expects($this->once()) + ->method('__invoke') + ->with(OutputTypeEnum::OUT, "### Staging '--version' in staging-dir\n"); + + $decorated->expects($this->once()) + ->method('stage') + ->with( + ['--version'], + $activeDir, + $stagingDir, + $this->isInstanceOf(FileProcessOutputCallback::class), + ); + + $config_factory = $this->getConfigFactoryStub([ + 'package_manager.settings' => ['log' => 'php://memory'], + ]); + $decorator = new LoggingStager($decorated, $config_factory); + $decorator->stage(['--version'], $activeDir, $stagingDir, callback: $original_callback); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php b/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php new file mode 100644 index 00000000000..abaa5ef6e7b --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php @@ -0,0 +1,123 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Core\File\FileSystemInterface; +use Drupal\package_manager\PathLocator; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\PathLocator + * @group package_manager + * @internal + */ +class PathLocatorTest extends UnitTestCase { + + /** + * @covers ::getStagingRoot + */ + public function testStagingRoot(): void { + $config_factory = $this->getConfigFactoryStub([ + 'system.site' => [ + 'uuid' => '_my_site_id', + ], + ]); + $file_system = $this->prophesize(FileSystemInterface::class); + $file_system->getTempDirectory()->willReturn('/path/to/temp'); + + $path_locator = new PathLocator( + '/path/to/drupal', + $config_factory, + $file_system->reveal() + ); + $this->assertSame('/path/to/temp/.package_manager_my_site_id', $path_locator->getStagingRoot()); + } + + /** + * Data provider for ::testWebRoot(). + * + * @return string[][] + * Sets of arguments to pass to the test method. + */ + public static function providerWebRoot(): array { + // In certain sites (like those created by drupal/recommended-project), the + // web root is a subdirectory of the project, and exists next to the + // vendor directory. + return [ + 'recommended project' => [ + '/path/to/project/www', + '/path/to/project', + 'www', + ], + 'recommended project with trailing slash on app root' => [ + '/path/to/project/www/', + '/path/to/project', + 'www', + ], + 'recommended project with trailing slash on project root' => [ + '/path/to/project/www', + '/path/to/project/', + 'www', + ], + 'recommended project with trailing slashes' => [ + '/path/to/project/www/', + '/path/to/project/', + 'www', + ], + // In legacy projects (i.e., created by drupal/legacy-project), the + // web root is the project root. + 'legacy project' => [ + '/path/to/drupal', + '/path/to/drupal', + '', + ], + 'legacy project with trailing slash on app root' => [ + '/path/to/drupal/', + '/path/to/drupal', + '', + ], + 'legacy project with trailing slash on project root' => [ + '/path/to/drupal', + '/path/to/drupal/', + '', + ], + 'legacy project with trailing slashes' => [ + '/path/to/drupal/', + '/path/to/drupal/', + '', + ], + ]; + } + + /** + * Tests that the web root is computed correctly. + * + * @param string $app_root + * The absolute path of the Drupal root. + * @param string $project_root + * The absolute path of the project root. + * @param string $expected_web_root + * The value expected from getWebRoot(). + * + * @covers ::getWebRoot + * + * @dataProvider providerWebRoot + */ + public function testWebRoot(string $app_root, string $project_root, string $expected_web_root): void { + $path_locator = $this->getMockBuilder(PathLocator::class) + // Mock all methods except getWebRoot(). + ->onlyMethods(['getProjectRoot', 'getStagingRoot', 'getVendorDirectory']) + ->setConstructorArgs([ + $app_root, + $this->getConfigFactoryStub(), + $this->prophesize(FileSystemInterface::class)->reveal(), + ]) + ->getMock(); + + $path_locator->method('getProjectRoot')->willReturn($project_root); + $this->assertSame($expected_web_root, $path_locator->getWebRoot()); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php b/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php new file mode 100644 index 00000000000..b401fbd119c --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use ColinODell\PsrTestLogger\TestLogger; +use Drupal\package_manager\ProcessOutputCallback; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum; + +/** + * @covers \Drupal\package_manager\ProcessOutputCallback + * @group package_manager + */ +class ProcessOutputCallbackTest extends UnitTestCase { + + /** + * Tests what happens when the output buffer has invalid JSON. + */ + public function testInvalidJson(): void { + $callback = new ProcessOutputCallback(); + $callback(OutputTypeEnum::OUT, '{A string of invalid JSON! 😈'); + + $this->expectException(\JsonException::class); + $this->expectExceptionMessage('Syntax error'); + $callback->parseJsonOutput(); + } + + /** + * Tests what happens when there is error output only. + */ + public function testErrorOutputOnly(): void { + $callback = new ProcessOutputCallback(); + $logger = new TestLogger(); + $callback->setLogger($logger); + + $error_text = 'What happened?'; + $callback(OutputTypeEnum::ERR, $error_text); + + $this->assertSame([$error_text], $callback->getErrorOutput()); + // The error should not yet be logged. + $this->assertEmpty($logger->records); + + // There should be no output data, but calling getOutput() should log the + // error. + $this->assertSame([], $callback->getOutput()); + $this->assertNull($callback->parseJsonOutput()); + $this->assertTrue($logger->hasWarning($error_text)); + + // Resetting the callback should clear the error buffer but the log should + // still have the error from before. + $callback->reset(); + $this->assertTrue($logger->hasWarning($error_text)); + } + + /** + * Tests the full lifecycle of a ProcessOutputCallback object. + */ + public function testCallback(): void { + $callback = new ProcessOutputCallback(); + $logger = new TestLogger(); + $callback->setLogger($logger); + + // The buffers should initially be empty, and nothing should be logged. + $this->assertSame([], $callback->getOutput()); + $this->assertSame([], $callback->getErrorOutput()); + $this->assertNull($callback->parseJsonOutput()); + $this->assertEmpty($logger->records); + + // Send valid JSON data to the callback, one line at a time. + $data = [ + 'value' => 'I have value!', + 'another value' => 'I have another value!', + 'one' => 1, + ]; + $json = json_encode($data, JSON_PRETTY_PRINT); + // Ensure the JSON is a multi-line string. + $this->assertGreaterThan(1, substr_count($json, "\n")); + $expected_output = []; + foreach (explode("\n", $json) as $line) { + $callback(OutputTypeEnum::OUT, "$line\n"); + $expected_output[] = "$line\n"; + } + $this->assertSame($expected_output, $callback->getOutput()); + // Ensure that parseJsonOutput() can parse the data without errors. + $this->assertSame($data, $callback->parseJsonOutput()); + $this->assertSame([], $callback->getErrorOutput()); + $this->assertEmpty($logger->records); + + // If we send error output, it should be logged, but we should still be able + // to get the data we already sent. + $callback(OutputTypeEnum::ERR, 'Oh no, what happened?'); + $callback(OutputTypeEnum::ERR, 'Really what happened?!'); + $this->assertSame($data, $callback->parseJsonOutput()); + $expected_error = ['Oh no, what happened?', 'Really what happened?!']; + $this->assertSame($expected_error, $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + + // Send more output and error data to the callback; they should be appended + // to the data we previously sent. + $callback(OutputTypeEnum::OUT, '{}'); + $expected_output[] = '{}'; + $callback(OutputTypeEnum::ERR, 'new Error 1!'); + $callback(OutputTypeEnum::ERR, 'new Error 2!'); + $expected_error[] = 'new Error 1!'; + $expected_error[] = 'new Error 2!'; + // The output buffer will no longer be valid JSON, so don't try to parse it. + $this->assertSame($expected_output, $callback->getOutput()); + $this->assertSame($expected_error, $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning(implode('', $expected_error))); + // The previously logged error output should still be there. + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + + // Clear all stored output and errors. + $callback->reset(); + $this->assertSame([], $callback->getOutput()); + $this->assertSame([], $callback->getErrorOutput()); + $this->assertNull($callback->parseJsonOutput()); + + // Send more output and error data. + $callback(OutputTypeEnum::OUT, 'Bonjour!'); + $callback(OutputTypeEnum::ERR, 'You continue to annoy me.'); + // We should now only see the stuff we just sent... + $this->assertSame(['Bonjour!'], $callback->getOutput()); + $this->assertSame(['You continue to annoy me.'], $callback->getErrorOutput()); + $this->assertTrue($logger->hasWarning('You continue to annoy me.')); + // ...but the previously logged errors should still be there. + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!new Error 1!new Error 2!')); + $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!')); + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php b/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php new file mode 100644 index 00000000000..91d7acd321d --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\Tests\UnitTestCase; + +/** + * @covers \Drupal\package_manager\Event\RequireEventTrait + * @group package_manager + * @internal + */ +class RequireEventTraitTest extends UnitTestCase { + + /** + * Tests that runtime and dev packages are keyed correctly. + * + * @param string[] $runtime_packages + * The runtime package constraints passed to the event constructor. + * @param string[] $dev_packages + * The dev package constraints passed to the event constructor. + * @param string[] $expected_runtime_packages + * The keyed runtime packages that should be returned by + * ::getRuntimePackages(). + * @param string[] $expected_dev_packages + * The keyed dev packages that should be returned by ::getDevPackages(). + * + * @dataProvider providerGetPackages + */ + public function testGetPackages(array $runtime_packages, array $dev_packages, array $expected_runtime_packages, array $expected_dev_packages): void { + $stage = $this->createMock('\Drupal\package_manager\StageBase'); + + $events = [ + '\Drupal\package_manager\Event\PostRequireEvent', + '\Drupal\package_manager\Event\PreRequireEvent', + ]; + foreach ($events as $event) { + /** @var \Drupal\package_manager\Event\RequireEventTrait $event */ + $event = new $event($stage, $runtime_packages, $dev_packages); + $this->assertSame($expected_runtime_packages, $event->getRuntimePackages()); + $this->assertSame($expected_dev_packages, $event->getDevPackages()); + } + } + + /** + * Data provider for testGetPackages(). + * + * @return mixed[] + * The test cases. + */ + public static function providerGetPackages(): array { + return [ + 'Package with constraint' => [ + ['drupal/new_package:^8.1'], + ['drupal/dev_package:^9'], + ['drupal/new_package' => '^8.1'], + ['drupal/dev_package' => '^9'], + ], + 'Package without constraint' => [ + ['drupal/new_package'], + ['drupal/dev_package'], + ['drupal/new_package' => '*'], + ['drupal/dev_package' => '*'], + ], + ]; + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/StageBaseTest.php b/core/modules/package_manager/tests/src/Unit/StageBaseTest.php new file mode 100644 index 00000000000..456a0e83e07 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/StageBaseTest.php @@ -0,0 +1,151 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\StageBase; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\StageBase + * @group package_manager + * @internal + */ +class StageBaseTest extends UnitTestCase { + + /** + * @covers ::validateRequirements + * + * @param string|null $expected_exception + * The exception class that should be thrown, or NULL if there should not be + * any exception. + * @param string $requirement + * The requirement (package name and optional constraint) to validate. + * + * @dataProvider providerValidateRequirements + */ + public function testValidateRequirements(?string $expected_exception, string $requirement): void { + $reflector = new \ReflectionClass(StageBase::class); + $method = $reflector->getMethod('validateRequirements'); + + if ($expected_exception) { + $this->expectException($expected_exception); + } + else { + $this->assertNull($expected_exception); + } + + $method->invoke(NULL, [$requirement]); + } + + /** + * Data provider for testValidateRequirements. + * + * @return array[] + * The test cases. + */ + public static function providerValidateRequirements(): array { + return [ + // Valid requirements. + [NULL, 'vendor/package'], + [NULL, 'vendor/snake_case'], + [NULL, 'vendor/kebab-case'], + [NULL, 'vendor/with.dots'], + [NULL, '1vendor2/3package4'], + [NULL, 'vendor/package:1'], + [NULL, 'vendor/package:1.2'], + [NULL, 'vendor/package:1.2.3'], + [NULL, 'vendor/package:1.x'], + [NULL, 'vendor/package:^1'], + [NULL, 'vendor/package:~1'], + [NULL, 'vendor/package:>1'], + [NULL, 'vendor/package:<1'], + [NULL, 'vendor/package:>=1'], + [NULL, 'vendor/package:>1 <2'], + [NULL, 'vendor/package:1 || 2'], + [NULL, 'vendor/package:>=1,<1.1.0'], + [NULL, 'vendor/package:1a'], + [NULL, 'vendor/package:*'], + [NULL, 'vendor/package:dev-master'], + [NULL, 'vendor/package:*@dev'], + [NULL, 'vendor/package:@dev'], + [NULL, 'vendor/package:master@dev'], + [NULL, 'vendor/package:master@beta'], + [NULL, 'php'], + [NULL, 'php:8'], + [NULL, 'php:8.0'], + [NULL, 'php:^8.1'], + [NULL, 'php:~8.1'], + [NULL, 'php-64bit'], + [NULL, 'composer'], + [NULL, 'composer-plugin-api'], + [NULL, 'composer-plugin-api:1'], + [NULL, 'ext-json'], + [NULL, 'ext-json:1'], + [NULL, 'ext-pdo_mysql'], + [NULL, 'ext-pdo_mysql:1'], + [NULL, 'lib-curl'], + [NULL, 'lib-curl:1'], + [NULL, 'lib-curl-zlib'], + [NULL, 'lib-curl-zlib:1'], + + // Invalid requirements. + [\InvalidArgumentException::class, ''], + [\InvalidArgumentException::class, ' '], + [\InvalidArgumentException::class, '/'], + [\InvalidArgumentException::class, 'php8'], + [\InvalidArgumentException::class, 'package'], + [\InvalidArgumentException::class, 'vendor\package'], + [\InvalidArgumentException::class, 'vendor//package'], + [\InvalidArgumentException::class, 'vendor/package1 vendor/package2'], + [\InvalidArgumentException::class, 'vendor/package/extra'], + [\UnexpectedValueException::class, 'vendor/package:a'], + [\UnexpectedValueException::class, 'vendor/package:'], + [\UnexpectedValueException::class, 'vendor/package::'], + [\UnexpectedValueException::class, 'vendor/package::1'], + [\UnexpectedValueException::class, 'vendor/package:1:2'], + [\UnexpectedValueException::class, 'vendor/package:develop@dev@dev'], + [\UnexpectedValueException::class, 'vendor/package:develop@'], + [\InvalidArgumentException::class, 'vEnDor/pAcKaGe'], + [\InvalidArgumentException::class, '_vendor/package'], + [\InvalidArgumentException::class, '_vendor/_package'], + [\InvalidArgumentException::class, 'vendor_/package'], + [\InvalidArgumentException::class, '_vendor/package_'], + [\InvalidArgumentException::class, 'vendor/package-'], + [\InvalidArgumentException::class, 'php-'], + [\InvalidArgumentException::class, 'ext'], + [\InvalidArgumentException::class, 'lib'], + ]; + } + + /** + * @covers ::getType + */ + public function testTypeMustBeExplicitlyOverridden(): void { + $good_grandchild = new class () extends ChildStage { + + /** + * {@inheritdoc} + */ + // phpcs:ignore DrupalPractice.CodeAnalysis.VariableAnalysis.UnusedVariable + protected string $type = 'package_manager:good_grandchild'; + + }; + $this->assertSame('package_manager:good_grandchild', $good_grandchild->getType()); + + $bad_grandchild = new class () extends ChildStage {}; + $this->expectException(\LogicException::class); + $this->expectExceptionMessage(get_class($bad_grandchild) . ' must explicitly override the $type property.'); + $bad_grandchild->getType(); + } + +} + +class ChildStage extends StageBase { + + public function __construct() {} + + protected string $type = 'package_manager:child'; + +} diff --git a/core/modules/package_manager/tests/src/Unit/StageNotInActiveValidatorTest.php b/core/modules/package_manager/tests/src/Unit/StageNotInActiveValidatorTest.php new file mode 100644 index 00000000000..1506d5450b8 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/StageNotInActiveValidatorTest.php @@ -0,0 +1,117 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\PathLocator; +use Drupal\package_manager\StageBase; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager\Validator\StageNotInActiveValidator; +use Drupal\Tests\package_manager\Traits\ValidationTestTrait; +use Drupal\Tests\UnitTestCase; +use PhpTuf\ComposerStager\API\Path\Value\PathListInterface; +use Symfony\Component\Filesystem\Path; + +/** + * @coversDefaultClass \Drupal\package_manager\Validator\StageNotInActiveValidator + * @group package_manager + * @internal + */ +class StageNotInActiveValidatorTest extends UnitTestCase { + use ValidationTestTrait; + + /** + * @covers ::validate + * + * @param \Drupal\package_manager\ValidationResult[] $expected + * The expected result. + * @param string $project_root + * The project root. + * @param string $staging_root + * The staging root. + * + * @dataProvider providerTestCheckNotInActive + */ + public function testCheckNotInActive(array $expected, string $project_root, string $staging_root): void { + $path_locator_prophecy = $this->prophesize(PathLocator::class); + $path_locator_prophecy->getProjectRoot()->willReturn(Path::canonicalize($project_root)); + $path_locator_prophecy->getStagingRoot()->willReturn(Path::canonicalize($staging_root)); + $path_locator_prophecy->getVendorDirectory()->willReturn('not used'); + $path_locator = $path_locator_prophecy->reveal(); + $stage = $this->prophesize(StageBase::class)->reveal(); + + $stage_not_in_active_validator = new StageNotInActiveValidator($path_locator); + $stage_not_in_active_validator->setStringTranslation($this->getStringTranslationStub()); + $event = new PreCreateEvent($stage, $this->createMock(PathListInterface::class)); + $stage_not_in_active_validator->validate($event); + $this->assertValidationResultsEqual($expected, $event->getResults(), $path_locator); + } + + /** + * Data provider for testCheckNotInActive(). + * + * @return mixed[] + * The test cases. + */ + public static function providerTestCheckNotInActive(): array { + $expected_symlink_validation_error = ValidationResult::createError([ + t('Stage directory is a subdirectory of the active directory.'), + ]); + + return [ + "Absolute paths which don't satisfy" => [ + [$expected_symlink_validation_error], + "/var/root", + "/var/root/xyz", + ], + "Absolute paths which satisfy" => [ + [], + "/var/root", + "/home/var/root", + ], + 'Stage with .. segments, outside active' => [ + [], + "/var/root/active", + "/var/root/active/../stage", + ], + 'Stage without .. segments, outside active' => [ + [], + "/var/root/active", + "/var/root/stage", + ], + 'Stage with .. segments, inside active' => [ + [$expected_symlink_validation_error], + "/var/root/active", + "/var/root/active/../active/stage", + ], + 'Stage without .. segments, inside active' => [ + [$expected_symlink_validation_error], + "/var/root/active", + "/var/root/active/stage", + ], + 'Stage with .. segments, outside active, active with .. segments' => [ + [], + "/var/root/active", + "/var/root/active/../stage", + ], + 'Stage without .. segments, outside active, active with .. segments' => [ + [], + "/var/root/random/../active", + "/var/root/stage", + ], + 'Stage with .. segments, inside active, active with .. segments' => [ + [$expected_symlink_validation_error], + "/var/root/random/../active", + "/var/root/active/../active/stage", + ], + 'Stage without .. segments, inside active, active with .. segments' => [ + [$expected_symlink_validation_error], + "/var/root/random/../active", + "/var/root/active/stage", + ], + ]; + } + +} diff --git a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php new file mode 100644 index 00000000000..5d87a5d1558 --- /dev/null +++ b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php @@ -0,0 +1,175 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\ValidationResult; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\system\SystemManager; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\ValidationResult + * @group package_manager + * @internal + */ +class ValidationResultTest extends UnitTestCase { + + /** + * @covers ::createWarning + * + * @dataProvider providerValidConstructorArguments + */ + public function testCreateWarningResult(array $messages, ?string $summary): void { + $summary = $summary ? t($summary) : NULL; + $result = ValidationResult::createWarning($messages, $summary); + $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_WARNING); + } + + /** + * @covers ::getOverallSeverity + */ + public function testOverallSeverity(): void { + // An error and a warning should be counted as an error. + $results = [ + ValidationResult::createError([t('Boo!')]), + ValidationResult::createWarning([t('Moo!')]), + ]; + $this->assertSame(SystemManager::REQUIREMENT_ERROR, ValidationResult::getOverallSeverity($results)); + + // If there are no results, but no errors, the results should be counted as + // a warning. + array_shift($results); + $this->assertSame(SystemManager::REQUIREMENT_WARNING, ValidationResult::getOverallSeverity($results)); + + // If there are just plain no results, we should get REQUIREMENT_OK. + array_shift($results); + $this->assertSame(SystemManager::REQUIREMENT_OK, ValidationResult::getOverallSeverity($results)); + } + + /** + * @covers ::createError + * + * @dataProvider providerValidConstructorArguments + */ + public function testCreateErrorResult(array $messages, ?string $summary): void { + $summary = $summary ? t($summary) : NULL; + $result = ValidationResult::createError($messages, $summary); + $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_ERROR); + } + + /** + * @covers ::createWarning + * + * @param string[] $messages + * The warning messages of the validation result. + * @param string $expected_exception_message + * The expected exception message. + * + * @dataProvider providerCreateExceptions + */ + public function testCreateWarningResultException(array $messages, string $expected_exception_message): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expected_exception_message); + ValidationResult::createWarning($messages, NULL); + } + + /** + * @covers ::createError + * + * @param string[] $messages + * The error messages of the validation result. + * @param string $expected_exception_message + * The expected exception message. + * + * @dataProvider providerCreateExceptions + */ + public function testCreateErrorResultException(array $messages, string $expected_exception_message): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage($expected_exception_message); + ValidationResult::createError($messages, NULL); + } + + /** + * Tests that the messages are asserted to be translatable. + * + * @testWith ["createError"] + * ["createWarning"] + */ + public function testMessagesMustBeTranslatable(string $method): void { + // When creating an error from a throwable, the message does not need to be + // translatable. + ValidationResult::createErrorFromThrowable(new \Exception('Burn it down.')); + + $this->expectException(\AssertionError::class); + $this->expectExceptionMessageMatches('/instanceof TranslatableMarkup/'); + ValidationResult::$method(['Not translatable!']); + } + + /** + * Data provider for test methods that test create exceptions. + * + * @return array[] + * The test cases. + */ + public static function providerCreateExceptions(): array { + return [ + '2 messages, no summary' => [ + [t('Something is wrong'), t('Something else is also wrong')], + 'If more than one message is provided, a summary is required.', + ], + 'no messages' => [ + [], + 'At least one message is required.', + ], + ]; + } + + /** + * Data provider for testCreateWarningResult(). + * + * @return mixed[] + * The test cases. + */ + public static function providerValidConstructorArguments(): array { + return [ + '1 message no summary' => [ + 'messages' => [t('Something is wrong')], + 'summary' => NULL, + ], + '2 messages has summary' => [ + 'messages' => [ + t('Something is wrong'), + t('Something else is also wrong'), + ], + 'summary' => 'This sums it up.', + ], + ]; + } + + /** + * Asserts a check result is valid. + * + * @param \Drupal\package_manager\ValidationResult $result + * The validation result to check. + * @param array $expected_messages + * The expected messages. + * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary + * The expected summary or NULL if not summary is expected. + * @param int $severity + * The severity. + */ + protected function assertResultValid(ValidationResult $result, array $expected_messages, ?TranslatableMarkup $summary, int $severity): void { + $this->assertSame($expected_messages, $result->messages); + if ($summary === NULL) { + $this->assertNull($result->summary); + } + else { + $this->assertSame($summary->getUntranslatedString(), $result->summary + ->getUntranslatedString()); + } + $this->assertSame($severity, $result->severity); + } + +} |