From 7e8c3d2818bcf3827308e532420d529bb720a253 Mon Sep 17 00:00:00 2001 From: Ted Bowman <ted@tedbow.com> Date: Thu, 6 Jan 2022 13:47:44 -0700 Subject: [PATCH] Contrib: Issue #3257115 by phenaproxima: Convert ExcludedPathsTest into a kernel test - https://git.drupalcode.org/project/automatic_updates/-/commit/46cab4c87b2c8c1816d109baa2c7a49caa59e0b1 --- .../auto_updates/auto_updates.services.yml | 6 - .../src/Event/ExcludedPathsSubscriber.php | 56 ---- .../tests/src/Build/CoreUpdateTest.php | 290 +++++++---------- .../src/Build/TemplateProjectSiteTestBase.php | 295 ++++++++++++++++++ .../tests/src/Build/UpdateTestBase.php | 240 +++----------- .../tests/src/Functional/UpdaterFormTest.php | 5 +- .../tests/src/Kernel/CronUpdaterTest.php | 5 +- .../StagedProjectsValidatorTest.php | 1 + .../tests/src/Kernel/UpdaterTest.php | 20 +- .../tests/src/Traits/JsonTrait.php | 41 --- .../tests/src/Traits/LocalPackagesTrait.php | 163 ---------- .../tests/src/Traits/SettingsTrait.php | 57 ---- .../package_manager.services.yml | 10 +- .../src/Event/ExcludedPathsTrait.php | 17 +- .../ComposerExecutableValidator.php | 10 +- .../ExcludedPathsSubscriber.php | 169 ++++++---- .../package_manager/src/FileSyncerFactory.php | 14 +- core/modules/package_manager/src/Stage.php | 7 +- .../tests/fixtures/fake_site/vendor/.htaccess | 1 + .../package_manager_bypass/src/Beginner.php | 4 +- .../package_manager_bypass/src/Committer.php | 4 +- .../package_manager_bypass/src/Stager.php | 4 +- .../src/Functional/ExcludedPathsTest.php | 155 --------- .../ComposerExecutableValidatorTest.php | 4 +- .../Kernel/ComposerSettingsValidatorTest.php | 1 + .../src/Kernel/DiskSpaceValidatorTest.php | 1 + .../Kernel/ExcludedPathsSubscriberTest.php | 10 +- .../tests/src/Kernel/ExcludedPathsTest.php | 227 ++++++++++++++ .../Kernel/PackageManagerKernelTestBase.php | 22 ++ .../WritableFileSystemValidatorTest.php | 1 + 30 files changed, 888 insertions(+), 952 deletions(-) delete mode 100644 core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php create mode 100644 core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php delete mode 100644 core/modules/auto_updates/tests/src/Traits/JsonTrait.php delete mode 100644 core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php delete mode 100644 core/modules/auto_updates/tests/src/Traits/SettingsTrait.php create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess delete mode 100644 core/modules/package_manager/tests/src/Functional/ExcludedPathsTest.php create mode 100644 core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php diff --git a/core/modules/auto_updates/auto_updates.services.yml b/core/modules/auto_updates/auto_updates.services.yml index b8715b3450e5..29b2fdbc1b6c 100644 --- a/core/modules/auto_updates/auto_updates.services.yml +++ b/core/modules/auto_updates/auto_updates.services.yml @@ -30,12 +30,6 @@ services: - '@file_system' - '@event_dispatcher' - '@tempstore.shared' - auto_updates.excluded_paths_subscriber: - class: Drupal\auto_updates\Event\ExcludedPathsSubscriber - arguments: - - '@extension.list.module' - tags: - - { name: event_subscriber } auto_updates.staged_projects_validator: class: Drupal\auto_updates\Validator\StagedProjectsValidator arguments: diff --git a/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php b/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php deleted file mode 100644 index a961b2b2ed6b..000000000000 --- a/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php +++ /dev/null @@ -1,56 +0,0 @@ -<?php - -namespace Drupal\auto_updates\Event; - -use Drupal\auto_updates\Updater; -use Drupal\Core\Extension\ModuleExtensionList; -use Drupal\package_manager\Event\PreCreateEvent; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; - -/** - * Defines an event subscriber to exclude certain paths from update operations. - */ -class ExcludedPathsSubscriber implements EventSubscriberInterface { - - /** - * The module list service. - * - * @var \Drupal\Core\Extension\ModuleExtensionList - */ - protected $moduleList; - - /** - * Constructs an UpdateSubscriber. - * - * @param \Drupal\Core\Extension\ModuleExtensionList $module_list - * The module list service. - */ - public function __construct(ModuleExtensionList $module_list) { - $this->moduleList = $module_list; - } - - /** - * Reacts to the beginning of an update process. - * - * @param \Drupal\package_manager\Event\PreCreateEvent $event - * The event object. - */ - public function preCreate(PreCreateEvent $event): void { - // If we are doing an automatic update and this module is a git clone, - // exclude it. - if ($event->getStage() instanceof Updater && is_dir(__DIR__ . '/../../.git')) { - $dir = $this->moduleList->getPath('auto_updates'); - $event->excludePath($dir); - } - } - - /** - * {@inheritdoc} - */ - public static function getSubscribedEvents() { - return [ - PreCreateEvent::class => 'preCreate', - ]; - } - -} diff --git a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php index a8e670912e41..be3070d61b21 100644 --- a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php +++ b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php @@ -2,7 +2,7 @@ namespace Drupal\Tests\auto_updates\Build; -use Drupal\Component\Serialization\Json; +use Drupal\Composer\Composer; /** * Tests an end-to-end update of Drupal core. @@ -14,153 +14,43 @@ class CoreUpdateTest extends UpdateTestBase { /** * {@inheritdoc} */ - protected function createTestSite(string $template): void { - $dir = $this->getWorkspaceDirectory(); - - // Build the test site and alter its copy of core so that it thinks it's - // running Drupal 9.8.0, which will never actually exist in the real world. - // Then, prepare a secondary copy of the core code base, masquerading as - // Drupal 9.8.1, which will be the version of core we update to. These two - // versions are referenced in the fake release metadata in our fake release - // metadata (see fixtures/release-history/drupal.0.0.xml). - parent::createTestSite($template); - $this->setCoreVersion($this->getWebRoot() . '/core', '9.8.0'); - $this->alterPackage($dir, $this->getConfigurationForUpdate('9.8.1')); - - // Install Drupal and ensure it's using the fake release metadata to fetch - // information about available updates. - $this->installQuickStart('minimal'); - $this->setReleaseMetadata(['drupal' => '9.8.1-security']); - $this->formLogin($this->adminUsername, $this->adminPassword); - $this->installModules([ - 'auto_updates', - 'auto_updates_test', - 'update_test', - ]); - - // 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 - // ComposerUtility 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 === 'drupal/recommended-project') { - $this->assertFileDoesNotExist($dir . '/.htaccess'); - } + public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) { + parent::copyCodebase($iterator, $working_dir); - // Ensure that Drupal thinks we are running 9.8.0, then refresh information - // about available updates. - $this->assertCoreVersion('9.8.0'); - $this->checkForUpdates(); - // Ensure that an update to 9.8.1 is available. - $this->visit('/admin/modules/automatic-update'); - $this->getMink()->assertSession()->pageTextContains('9.8.1'); + // Ensure that we will install Drupal 9.8.0 (a fake version that should + // never exist in real life) initially. + $this->setUpstreamCoreVersion('9.8.0'); } /** * {@inheritdoc} */ - protected function tearDown(): void { - if ($this->destroyBuild) { - $this->deleteCopiedPackages(); - } - parent::tearDown(); - } - - /** - * Modifies a Drupal core code base to set its version. - * - * @param string $dir - * The directory of the Drupal core code base. - * @param string $version - * The version number to set. - */ - private function setCoreVersion(string $dir, string $version): void { - $this->alterPackage($dir, ['version' => $version]); - - $drupal_php = "$dir/lib/Drupal.php"; - $this->assertIsWritable($drupal_php); - $code = file_get_contents($drupal_php); - $code = preg_replace("/const VERSION = '([0-9]+\.?){3}(-dev)?';/", "const VERSION = '$version';", $code); - file_put_contents($drupal_php, $code); + public function getCodebaseFinder() { + // Don't copy .git directories and such, since that just slows things down. + // We can use ::setUpstreamCoreVersion() to explicitly set the versions of + // core packages required by the test site. + return parent::getCodebaseFinder()->ignoreVCS(TRUE); } /** - * Returns composer.json changes that are needed to update core. - * - * This will clone the following packages into temporary directories: - * - drupal/core - * - drupal/core-recommended - * - drupal/core-project-message - * - drupal/core-composer-scaffold - * The cloned packages will be assigned the given version number, and the test - * site's composer.json will use the clones as path repositories. - * - * @param string $version - * The version of core we will be updating to. - * - * @return array - * The changes to merge into the test site's composer.json. + * {@inheritdoc} */ - protected function getConfigurationForUpdate(string $version): array { - $repositories = []; - - // Create a fake version of core with the given version number, and change - // its README so that we can actually be certain that we update to this - // fake version. - $dir = $this->copyPackage($this->getWebRoot() . '/core'); - $this->setCoreVersion($dir, $version); - file_put_contents("$dir/README.txt", "Placeholder for Drupal core $version."); - $repositories['drupal/core'] = $this->createPathRepository($dir); - - $drupal_root = $this->getDrupalRoot(); - - // Create a fake version of drupal/core-recommended which itself requires - // the fake version of core we just created. - $dir = $this->copyPackage("$drupal_root/composer/Metapackage/CoreRecommended"); - $this->alterPackage($dir, [ - 'require' => [ - 'drupal/core' => $version, - ], - 'version' => $version, - ]); - $repositories['drupal/core-recommended'] = $this->createPathRepository($dir); - - // Create fake target versions of core plugins and metapackages. - $packages = [ - 'drupal/core-dev' => "$drupal_root/composer/Metapackage/DevDependencies", - 'drupal/core-project-message' => "$drupal_root/composer/Plugin/ProjectMessage", - 'drupal/core-composer-scaffold' => "$drupal_root/composer/Plugin/Scaffold", - 'drupal/core-vendor-hardening' => "$drupal_root/composer/Plugin/VendorHardening", - ]; - foreach ($packages as $name => $dir) { - $dir = $this->copyPackage($dir); - $this->alterPackage($dir, ['version' => $version]); - $repositories[$name] = $this->createPathRepository($dir); - } - - return [ - 'repositories' => $repositories, - ]; - } + protected function createTestProject(string $template): void { + parent::createTestProject($template); + + // Prepare an "upstream" version of core, 9.8.1, to which we will update. + // This version, along with 9.8.0 (which was installed initially), is + // referenced in our fake release metadata (see + // fixtures/release-history/drupal.0.0.xml). + $this->setUpstreamCoreVersion('9.8.1'); + $this->setReleaseMetadata(['drupal' => '9.8.1-security']); - /** - * Data provider for end-to-end update tests. - * - * @return array[] - * Sets of arguments to pass to the test method. - */ - public function providerTemplate(): array { - return [ - ['drupal/recommended-project'], - ['drupal/legacy-project'], - ]; + // Ensure that Drupal thinks we are running 9.8.0, then refresh information + // about available updates and ensure that an update to 9.8.1 is available. + $this->assertCoreVersion('9.8.0'); + $this->checkForUpdates(); + $this->visit('/admin/modules/automatic-update'); + $this->getMink()->assertSession()->pageTextContains('9.8.1'); } /** @@ -172,14 +62,14 @@ public function providerTemplate(): array { * @dataProvider providerTemplate */ public function testApi(string $template): void { - $this->createTestSite($template); + $this->createTestProject($template); $mink = $this->getMink(); $assert_session = $mink->assertSession(); // Ensure that the update is prevented if the web root and/or vendor // directories are not writable. - $this->assertReadOnlyFileSystemError($template, '/automatic-update-test/update/9.8.1'); + $this->assertReadOnlyFileSystemError('/automatic-update-test/update/9.8.1'); $mink->getSession()->reload(); $assert_session->pageTextContains('9.8.1'); @@ -194,7 +84,7 @@ public function testApi(string $template): void { * @dataProvider providerTemplate */ public function testUi(string $template): void { - $this->createTestSite($template); + $this->createTestProject($template); $mink = $this->getMink(); $session = $mink->getSession(); @@ -207,7 +97,7 @@ public function testUi(string $template): void { // Ensure that the update is prevented if the web root and/or vendor // directories are not writable. - $this->assertReadOnlyFileSystemError($template, parse_url($session->getCurrentUrl(), PHP_URL_PATH)); + $this->assertReadOnlyFileSystemError(parse_url($session->getCurrentUrl(), PHP_URL_PATH)); $session->reload(); $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); @@ -218,7 +108,7 @@ public function testUi(string $template): void { $this->waitForBatchJob(); $assert_session->pageTextContains('Update complete!'); $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.'); - $this->assertUpdateSuccessful(); + $this->assertUpdateSuccessful('9.8.1'); } /** @@ -230,43 +120,35 @@ public function testUi(string $template): void { * @dataProvider providerTemplate */ public function testCron(string $template): void { - $this->createTestSite($template); + $this->createTestProject($template); $this->visit('/admin/reports/status'); $this->getMink()->getSession()->getPage()->clickLink('Run cron'); - $this->assertUpdateSuccessful(); + $this->assertUpdateSuccessful('9.8.1'); } /** * Asserts that the update is prevented if the filesystem isn't writable. * - * @param string $template - * The project template used to build the test site. See ::createTestSite() - * for the possible values. - * @param string $url + * @param string $error_url * A URL where we can see the error message which is raised when parts of * the file system are not writable. This URL will be visited twice: once * for the web root, and once for the vendor directory. */ - private function assertReadOnlyFileSystemError(string $template, string $url): void { + private function assertReadOnlyFileSystemError(string $error_url): void { $directories = [ 'Drupal' => rtrim($this->getWebRoot(), './'), ]; // The location of the vendor directory depends on which project template - // was used to build the test site. - if ($template === 'drupal/recommended-project') { - $directories['vendor'] = $this->getWorkspaceDirectory() . '/vendor'; - } - elseif ($template === 'drupal/legacy-project') { - $directories['vendor'] = $directories['Drupal'] . '/vendor'; - } + // was used to build the test site, so just ask Composer where it is. + $directories['vendor'] = $this->runComposer('composer config --absolute vendor-dir', 'project'); $assert_session = $this->getMink()->assertSession(); foreach ($directories as $type => $path) { chmod($path, 0555); $this->assertDirectoryIsNotWritable($path); - $this->visit($url); + $this->visit($error_url); $assert_session->pageTextContains("The $type directory \"$path\" is not writable."); chmod($path, 0755); $this->assertDirectoryIsWritable($path); @@ -274,43 +156,97 @@ private function assertReadOnlyFileSystemError(string $template, string $url): v } /** - * Asserts that Drupal core was successfully updated. + * Sets the version of Drupal core to which the test site will be updated. + * + * @param string $version + * The Drupal core version to set. + */ + private function setUpstreamCoreVersion(string $version): void { + $workspace_dir = $this->getWorkspaceDirectory(); + + // Loop through core's metapackages and plugins, and alter them as needed. + $packages = str_replace("$workspace_dir/", NULL, $this->getCorePackages()); + foreach ($packages as $path) { + // Assign the new upstream version. + $this->runComposer("composer config version $version", $path); + + // If this package requires Drupal core (e.g., drupal/core-recommended), + // make it require the new upstream version. + $info = $this->runComposer('composer info --self --format json', $path, TRUE); + if (isset($info['requires']['drupal/core'])) { + $this->runComposer("composer require --no-update drupal/core:$version", $path); + } + } + + // Change the \Drupal::VERSION constant and put placeholder text in the + // README so we can ensure that we really updated to the correct version. + // @see ::assertUpdateSuccessful() + Composer::setDrupalVersion($workspace_dir, $version); + file_put_contents("$workspace_dir/core/README.txt", "Placeholder for Drupal core $version."); + } + + /** + * Asserts that a specific version of Drupal core is running. + * + * Assumes that a user with permission to view the status report is logged in. + * + * @param string $expected_version + * The version of core that should be running. + */ + protected function assertCoreVersion(string $expected_version): void { + $this->visit('/admin/reports/status'); + $item = $this->getMink() + ->assertSession() + ->elementExists('css', 'h3:contains("Drupal Version")') + ->getParent() + ->getText(); + $this->assertStringContainsString($expected_version, $item); + } + + /** + * Asserts that Drupal core was updated successfully. + * + * Assumes that a user with appropriate permissions is logged in. + * + * @param string $expected_version + * The expected active version of Drupal core. */ - private function assertUpdateSuccessful(): void { + private function assertUpdateSuccessful(string $expected_version): void { // The update form should not have any available updates. // @todo Figure out why this assertion fails when the batch processor // redirects directly to the update form, instead of update.status, when // updating via the UI. $this->visit('/admin/modules/automatic-update'); $this->getMink()->assertSession()->pageTextContains('No update available'); - // The status page should report that we're running Drupal 9.8.1. - $this->assertCoreVersion('9.8.1'); - // The fake placeholder text from ::getConfigurationForUpdate() should be - // present in the README. + + // The status page should report that we're running the expected version and + // the README should contain the placeholder text written by + // ::setUpstreamCoreVersion(). + $this->assertCoreVersion($expected_version); $placeholder = file_get_contents($this->getWebRoot() . '/core/README.txt'); - $this->assertSame('Placeholder for Drupal core 9.8.1.', $placeholder); + $this->assertSame("Placeholder for Drupal core $expected_version.", $placeholder); + + $info = $this->runComposer('composer info --self --format json', 'project', TRUE); - $composer = file_get_contents($this->getWorkspaceDirectory() . '/composer.json'); - $composer = Json::decode($composer); // The production dependencies should have been updated. - $this->assertSame('9.8.1', $composer['require']['drupal/core-recommended']); - $this->assertSame('9.8.1', $composer['require']['drupal/core-composer-scaffold']); - $this->assertSame('9.8.1', $composer['require']['drupal/core-project-message']); + $this->assertSame($expected_version, $info['requires']['drupal/core-recommended']); + $this->assertSame($expected_version, $info['requires']['drupal/core-composer-scaffold']); + $this->assertSame($expected_version, $info['requires']['drupal/core-project-message']); // The core-vendor-hardening plugin is only used by the legacy project // template. - if ($composer['name'] === 'drupal/legacy-project') { - $this->assertSame('9.8.1', $composer['require']['drupal/core-vendor-hardening']); + if ($info['name'] === 'drupal/legacy-project') { + $this->assertSame($expected_version, $info['requires']['drupal/core-vendor-hardening']); } // The production dependencies should not be listed as dev dependencies. - $this->assertArrayNotHasKey('drupal/core-recommended', $composer['require-dev']); - $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $composer['require-dev']); - $this->assertArrayNotHasKey('drupal/core-project-message', $composer['require-dev']); - $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $composer['require-dev']); + $this->assertArrayNotHasKey('drupal/core-recommended', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-project-message', $info['devRequires']); + $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $info['devRequires']); // The drupal/core-dev metapackage should not be a production dependency... - $this->assertArrayNotHasKey('drupal/core-dev', $composer['require']); + $this->assertArrayNotHasKey('drupal/core-dev', $info['requires']); // ...but it should have been updated in the dev dependencies. - $this->assertSame('9.8.1', $composer['require-dev']['drupal/core-dev']); + $this->assertSame($expected_version, $info['devRequires']['drupal/core-dev']); } } diff --git a/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php b/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php new file mode 100644 index 000000000000..dbf0d3d8301c --- /dev/null +++ b/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php @@ -0,0 +1,295 @@ +<?php + +namespace Drupal\Tests\auto_updates\Build; + +use Drupal\BuildTests\QuickStart\QuickStartTestBase; +use Drupal\Composer\Composer; + +/** + * Base class for tests which create a test site from a core project template. + */ +abstract class TemplateProjectSiteTestBase extends QuickStartTestBase { + + /** + * The web root of the test site, relative to the workspace directory. + * + * @var string + */ + private $webRoot; + + /** + * Data provider for tests which use all of the core project templates. + * + * @return array[] + * Sets of arguments to pass to the test method. + */ + public function providerTemplate(): array { + return [ + ['RecommendedProject'], + ['LegacyProject'], + ]; + } + + /** + * {@inheritdoc} + */ + public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) { + parent::copyCodebase($iterator, $working_dir); + + // 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. + $this->runComposer('composer remove --no-update drupal/auto_updates', 'composer/Metapackage/CoreRecommended'); + } + + /** + * {@inheritdoc} + */ + public function getCodebaseFinder() { + // If core's npm dependencies are installed, we don't want them to be + // included in the upstream version of core that gets installed into the + // test site. + return parent::getCodebaseFinder()->notPath('#^core/node_modules#'); + } + + /** + * 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) { + return parent::instantiateServer($port, $working_dir ?: $this->webRoot); + } + + /** + * {@inheritdoc} + */ + public function installQuickStart($profile, $working_dir = NULL) { + parent::installQuickStart($profile, $working_dir ?: $this->webRoot); + } + + /** + * {@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) { + parent::formLogin($username, $password, $working_dir ?: $this->webRoot); + } + + /** + * Returns the paths of all core Composer packages. + * + * @return string[] + * The paths of the core Composer packages, keyed by parent directory name. + */ + protected function getCorePackages(): array { + $workspace_dir = $this->getWorkspaceDirectory(); + + $packages = [ + 'core' => "$workspace_dir/core", + ]; + foreach (['Metapackage', 'Plugin'] as $type) { + foreach (Composer::composerSubprojectPaths($workspace_dir, $type) as $package) { + $path = $package->getPath(); + $name = basename($path); + $packages[$name] = $path; + } + } + return $packages; + } + + /** + * 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(); + $template_dir = "composer/Template/$template"; + + // Remove the packages.drupal.org entry (and any other custom repository) + // from the template's repositories section. We have no reliable way of + // knowing the repositories' names in advance, so we get that information + // from `composer config`, and use `composer config --unset` to actually + // modify the template, to ensure it's done correctly. + $repositories = $this->runComposer('composer config repo', $template_dir, TRUE); + + foreach (array_keys($repositories) as $name) { + $this->runComposer("composer config --unset repo.$name", $template_dir); + } + + // Add all core plugins and metapackages as path repositories. To disable + // symlinking, we need to pass the JSON representations of the repositories + // to `composer config`. + foreach ($this->getCorePackages() as $name => $path) { + $repository = [ + 'type' => 'path', + 'url' => $path, + 'options' => [ + 'symlink' => FALSE, + ], + ]; + $repository = json_encode($repository, JSON_UNESCAPED_SLASHES); + $this->runComposer("composer config repo.$name '$repository'", $template_dir); + } + + // Add a local Composer repository with all third-party dependencies. + $vendor = "$workspace_dir/vendor.json"; + file_put_contents($vendor, json_encode($this->createVendorRepository(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + $this->runComposer("composer config repo.vendor composer file://$vendor", $template_dir); + + // Disable Packagist entirely so that we don't test the Internet. + $this->runComposer('composer config repo.packagist.org false', $template_dir); + + // Create the test project, defining its repository as part of the + // `composer create-project` command. + $repository = [ + 'type' => 'path', + 'url' => $template_dir, + ]; + $command = sprintf( + "COMPOSER_MIRROR_PATH_REPOS=1 composer create-project %s project --stability dev --repository '%s'", + $this->runComposer('composer config name', $template_dir), + json_encode($repository, JSON_UNESCAPED_SLASHES) + ); + // 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($command)); + + // 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 + // ComposerUtility 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/' . $this->runComposer('composer config extra.drupal-scaffold.locations.web-root', 'project'); + } + + /** + * Creates a Composer repository for all installed third-party dependencies. + * + * @return array + * The data that should be written to the repository file. + */ + protected function createVendorRepository(): array { + $packages = []; + $drupal_root = $this->getDrupalRoot(); + + foreach ($this->getPackagesFromLockFile() as $package) { + $name = $package['name']; + $path = "$drupal_root/vendor/$name"; + + // We are building a set of path repositories to projects in the vendor + // directory, so we will skip any project that does not exist in vendor. + // Also skip the projects that are symlinked in vendor. These are in our + // metapackage and will be represented as path repositories in the test + // project's composer.json. + if (is_dir($path) && !is_link($path)) { + unset( + // Force the package to be installed from our 'dist' information. + $package['source'], + // Don't notify anybody that we're installing this package. + $package['notification-url'], + // Since Drupal 9 requires PHP 7.3 or later, these polyfills won't be + // installed, so we should make sure that they're not required by + // anything. + $package['require']['symfony/polyfill-php72'], + $package['require']['symfony/polyfill-php73'] + ); + // 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(). + $package['dist'] = [ + 'type' => 'path', + 'url' => $path, + ]; + $version = $package['version']; + $packages[$name][$version] = $package; + } + } + return ['packages' => $packages]; + } + + /** + * Returns all package information from the lock file. + * + * @return array[] + * All package data from the lock file. + */ + private function getPackagesFromLockFile(): array { + $lock = $this->getDrupalRoot() . '/composer.lock'; + $this->assertFileExists($lock); + + $lock = file_get_contents($lock); + $lock = json_decode($lock, TRUE, JSON_THROW_ON_ERROR); + + $lock += [ + 'packages' => [], + 'packages-dev' => [], + ]; + return array_merge($lock['packages'], $lock['packages-dev']); + } + + /** + * 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 $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) { + $output = $this->executeCommand($command, $working_dir)->getOutput(); + $this->assertCommandSuccessful(); + + $output = trim($output); + if ($json) { + $output = json_decode($output, TRUE, JSON_THROW_ON_ERROR); + } + return $output; + } + +} diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php index 7bd97a62b0ad..dae6215766f1 100644 --- a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php +++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php @@ -2,22 +2,12 @@ namespace Drupal\Tests\auto_updates\Build; -use Drupal\BuildTests\QuickStart\QuickStartTestBase; -use Drupal\Component\Serialization\Json; use Drupal\Component\Utility\Html; -use Drupal\Tests\auto_updates\Traits\LocalPackagesTrait; -use Drupal\Tests\auto_updates\Traits\SettingsTrait; /** * Base class for tests that perform in-place updates. */ -abstract class UpdateTestBase extends QuickStartTestBase { - - use LocalPackagesTrait { - getPackagePath as traitGetPackagePath; - copyPackage as traitCopyPackage; - } - use SettingsTrait; +abstract class UpdateTestBase extends TemplateProjectSiteTestBase { /** * A secondary server instance, to serve XML metadata about available updates. @@ -26,15 +16,6 @@ abstract class UpdateTestBase extends QuickStartTestBase { */ private $metadataServer; - /** - * The test site's document root, relative to the workspace directory. - * - * @var string - * - * @see ::createTestSite() - */ - private $webRoot; - /** * {@inheritdoc} */ @@ -48,36 +29,54 @@ protected function tearDown(): void { /** * {@inheritdoc} */ - protected function copyPackage(string $source_dir, string $destination_dir = NULL): string { - return $this->traitCopyPackage($source_dir, $destination_dir ?: $this->getWorkspaceDirectory()); - } - - /** - * {@inheritdoc} - */ - protected function getPackagePath(array $package): string { - if ($package['name'] === 'drupal/core') { - return 'core'; - } + protected function createTestProject(string $template): void { + parent::createTestProject($template); - [$vendor, $name] = explode('/', $package['name']); + // Install Automatic Updates into the test project and ensure it wasn't + // symlinked. + $dir = 'project'; + $this->runComposer('composer config repo.auto_updates path ' . __DIR__ . '/../../..', $dir); + $this->runComposer('composer require --no-update "drupal/auto_updates:@dev"', $dir); + $output = $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer update --with-all-dependencies', $dir); + $this->assertStringNotContainsString('Symlinking', $output); - // Assume any contributed module is in modules/contrib/$name. - if ($vendor === 'drupal' && $package['type'] === 'drupal-module') { - return implode(DIRECTORY_SEPARATOR, ['modules', 'contrib', $name]); - } - - return $this->traitGetPackagePath($package); + // Install Drupal. Always allow test modules to be installed in the UI and, + // for easier debugging, always display errors in their dubious glory. + $this->installQuickStart('minimal'); + $php = <<<END +\$settings['extension_discovery_scan_tests'] = TRUE; +\$config['system.logging']['error_level'] = 'verbose'; +END; + $this->writeSettings($php); + + // Install Automatic Updates and other modules needed for testing. + $this->formLogin($this->adminUsername, $this->adminPassword); + $this->installModules([ + 'auto_updates', + 'auto_updates_test', + 'update_test', + ]); } /** - * Returns the full path to the test site's document root. + * Appends PHP code to the test site's settings.php. * - * @return string - * The full path of the test site's document root. - */ - protected function getWebRoot(): string { - return $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $this->webRoot; + * @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); + + $stream = fopen($file, 'a'); + $this->assertIsResource($stream); + $this->assertIsInt(fwrite($stream, $php)); + $this->assertTrue(fclose($stream)); } /** @@ -100,161 +99,12 @@ protected function setReleaseMetadata(array $xml_map): void { // about available updates. if (empty($this->metadataServer)) { $port = $this->findAvailablePort(); - $this->metadataServer = $this->instantiateServer($port, $this->webRoot); + $this->metadataServer = $this->instantiateServer($port); $code .= <<<END \$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test'; END; } - $this->addSettings($code, $this->getWebRoot()); - } - - /** - * {@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) { - parent::formLogin($username, $password, $working_dir ?: $this->webRoot); - } - - /** - * {@inheritdoc} - */ - public function installQuickStart($profile, $working_dir = NULL) { - parent::installQuickStart($profile, $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->addSettings($php, $this->getWebRoot()); - } - - /** - * Uses our already-installed dependencies to build a test site to update. - * - * @param string $template - * The template project from which to build the test site. Can be - * 'drupal/recommended-project' or 'drupal/legacy-project'. - */ - protected function createTestSite(string $template): void { - // Create the test site using one of the core project templates, but don't - // install dependencies just yet. - $template_dir = implode(DIRECTORY_SEPARATOR, [ - $this->getDrupalRoot(), - 'composer', - 'Template', - ]); - $recommended_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'RecommendedProject'); - $legacy_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'LegacyProject'); - - $dir = $this->getWorkspaceDirectory(); - $command = sprintf( - "composer create-project %s %s --no-install --stability dev --repository '%s' --repository '%s'", - $template, - $dir, - Json::encode($recommended_template), - Json::encode($legacy_template) - ); - $this->executeCommand($command); - $this->assertCommandSuccessful(); - - $composer = $dir . DIRECTORY_SEPARATOR . 'composer.json'; - $data = $this->readJson($composer); - - // Allow the test to configure the test site as necessary. - $data = $this->getInitialConfiguration($data); - - // We need to know the path of the web root, relative to the project root, - // in order to install Drupal or visit the test site at all. Luckily, both - // template projects define this because the scaffold plugin needs to know - // it as well. - // @see ::visit() - // @see ::formLogin() - // @see ::installQuickStart() - $this->webRoot = $data['extra']['drupal-scaffold']['locations']['web-root']; - - // Update the test site's composer.json. - $this->writeJson($composer, $data); - // Install dependencies, including dev. - $this->executeCommand('composer install'); - $this->assertCommandSuccessful(); - } - - /** - * Returns the initial data to write to the test site's composer.json. - * - * This configuration will be used to build the pre-update test site. - * - * @param array $data - * The current contents of the test site's composer.json. - * - * @return array - * The data that should be written to the test site's composer.json. - */ - protected function getInitialConfiguration(array $data): array { - $drupal_root = $this->getDrupalRoot(); - $core_composer_dir = $drupal_root . DIRECTORY_SEPARATOR . 'composer'; - $repositories = []; - - // Add all the metapackages that are provided by Drupal core. - $metapackage_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Metapackage'; - $repositories['drupal/core-recommended'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'CoreRecommended'); - $repositories['drupal/core-dev'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'DevDependencies'); - - // Add all the Composer plugins that are provided by Drupal core. - $plugin_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Plugin'; - $repositories['drupal/core-project-message'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'ProjectMessage'); - $repositories['drupal/core-composer-scaffold'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'Scaffold'); - $repositories['drupal/core-vendor-hardening'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'VendorHardening'); - - $repositories = array_merge($repositories, $this->getLocalPackageRepositories($drupal_root)); - // To ensure the test runs entirely offline, don't allow Composer to contact - // Packagist. - $repositories['packagist.org'] = FALSE; - - $repositories['drupal/auto_updates'] = [ - 'type' => 'path', - 'url' => __DIR__ . '/../../..', - ]; - // Use whatever the current branch of auto_updates is. - $data['require']['drupal/auto_updates'] = '*'; - - $data['repositories'] = $repositories; - - // Since Drupal 9 requires PHP 7.3 or later, these packages are probably - // not installed, which can cause trouble during dependency resolution. - // The drupal/drupal package (defined with a composer.json that is part - // of core's repository) replaces these, so we need to emulate that here. - $data['replace']['symfony/polyfill-php72'] = '*'; - $data['replace']['symfony/polyfill-php73'] = '*'; - - return $data; - } - - /** - * Asserts that a specific version of Drupal core is running. - * - * Assumes that a user with permission to view the status report is logged in. - * - * @param string $expected_version - * The version of core that should be running. - */ - protected function assertCoreVersion(string $expected_version): void { - $this->visit('/admin/reports/status'); - $item = $this->getMink() - ->assertSession() - ->elementExists('css', 'h3:contains("Drupal Version")') - ->getParent() - ->getText(); - $this->assertStringContainsString($expected_version, $item); + $this->writeSettings($code); } /** diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php index 0a942b4ececf..764733da649e 100644 --- a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php +++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php @@ -282,7 +282,10 @@ private function assertUpdateStagedTimes(int $attempted_times): void { /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */ $stager = $this->container->get('package_manager.stager'); - $this->assertCount($attempted_times, $stager->getInvocationArguments()); + // If an update was attempted, then there will be two calls to the stager: + // one to change the constraints in composer.json, and another to actually + // update the installed dependencies. + $this->assertCount($attempted_times * 2, $stager->getInvocationArguments()); /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $committer */ $committer = $this->container->get('package_manager.committer'); diff --git a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php index 52088d6a674c..72f12cec97a4 100644 --- a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php @@ -110,7 +110,10 @@ public function testUpdaterCalled(string $setting, string $release_data, bool $w $will_update = (int) $will_update; $this->assertCount($will_update, $this->container->get('package_manager.beginner')->getInvocationArguments()); - $this->assertCount($will_update, $this->container->get('package_manager.stager')->getInvocationArguments()); + // If updates happen, then there will be two calls to the stager: one to + // change the constraints in composer.json, and another to actually update + // the installed dependencies. + $this->assertCount($will_update * 2, $this->container->get('package_manager.stager')->getInvocationArguments()); $this->assertCount($will_update, $this->container->get('package_manager.committer')->getInvocationArguments()); } diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php index 7c07a2b9731e..c84e7c5e7a66 100644 --- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php @@ -64,6 +64,7 @@ private function validate(string $active_dir, string $stage_dir): array { $locator->getActiveDirectory()->willReturn($active_dir); $locator->getProjectRoot()->willReturn($active_dir); + $locator->getWebRoot()->willReturn(''); $locator->getVendorDirectory()->willReturn($active_dir); $stage_dir_exists = is_dir($stage_dir); diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php index ad7e46193b85..974de5cc7052 100644 --- a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php +++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php @@ -51,6 +51,7 @@ public function testCorrectVersionsStaged() { $locator = $this->prophesize(PathLocator::class); $locator->getActiveDirectory()->willReturn($fixture_dir); $locator->getProjectRoot()->willReturn($fixture_dir); + $locator->getWebRoot()->willReturn(''); $locator->getVendorDirectory()->willReturn($fixture_dir); $this->container->set('package_manager.path_locator', $locator->reveal()); @@ -74,26 +75,29 @@ public function testCorrectVersionsStaged() { // The production dependencies should be updated first... $expected_require_arguments = [ 'require', + '--no-update', 'drupal/core-recommended:9.8.1', - '--update-with-all-dependencies', ]; // ...followed by the dev dependencies. $expected_require_dev_arguments = [ 'require', + '--no-update', 'drupal/core-dev:9.8.1', - '--update-with-all-dependencies', '--dev', ]; + // In both cases, `composer update` will be called separately. + $expected_update_arguments = ['update', '--with-all-dependencies']; + $this->container->get('auto_updates.updater')->claim($id)->stage(); /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */ $stager = $this->container->get('package_manager.stager'); - [ - $actual_require_arguments, - $actual_require_dev_arguments, - ] = $stager->getInvocationArguments(); - $this->assertSame($expected_require_arguments, $actual_require_arguments[0]); - $this->assertSame($expected_require_dev_arguments, $actual_require_dev_arguments[0]); + $invocation_arguments = $stager->getInvocationArguments(); + $this->assertCount(4, $invocation_arguments); + $this->assertSame($expected_require_arguments, $invocation_arguments[0][0]); + $this->assertSame($expected_update_arguments, $invocation_arguments[1][0]); + $this->assertSame($expected_require_dev_arguments, $invocation_arguments[2][0]); + $this->assertSame($expected_update_arguments, $invocation_arguments[3][0]); } /** diff --git a/core/modules/auto_updates/tests/src/Traits/JsonTrait.php b/core/modules/auto_updates/tests/src/Traits/JsonTrait.php deleted file mode 100644 index 3c89c720bbe6..000000000000 --- a/core/modules/auto_updates/tests/src/Traits/JsonTrait.php +++ /dev/null @@ -1,41 +0,0 @@ -<?php - -namespace Drupal\Tests\auto_updates\Traits; - -use Drupal\Component\Serialization\Json; -use PHPUnit\Framework\Assert; - -/** - * Provides assertive methods to read and write JSON data in files. - */ -trait JsonTrait { - - /** - * Reads JSON data from a file and returns it as an array. - * - * @param string $path - * The path of the file to read. - * - * @return mixed[] - * The parsed data in the file. - */ - protected function readJson(string $path): array { - Assert::assertIsReadable($path); - $data = file_get_contents($path); - return Json::decode($data); - } - - /** - * Writes an array of data to a file as JSON. - * - * @param string $path - * The path of the file to write. - * @param array $data - * The data to be written. - */ - protected function writeJson(string $path, array $data): void { - Assert::assertIsWritable(file_exists($path) ? $path : dirname($path)); - file_put_contents($path, Json::encode($data)); - } - -} diff --git a/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php b/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php deleted file mode 100644 index 6b2d0407270e..000000000000 --- a/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php +++ /dev/null @@ -1,163 +0,0 @@ -<?php - -namespace Drupal\Tests\auto_updates\Traits; - -use Drupal\Component\FileSystem\FileSystem; -use Drupal\Component\Utility\NestedArray; -use PHPUnit\Framework\Assert; -use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; - -/** - * Provides methods for interacting with installed Composer packages. - */ -trait LocalPackagesTrait { - - use JsonTrait; - - /** - * The paths of temporary copies of packages. - * - * @see ::copyPackage() - * @see ::deleteCopiedPackages() - * - * @var string[] - */ - private $copiedPackages = []; - - /** - * Returns the path of an installed package, relative to composer.json. - * - * @param array $package - * The package information, as read from the lock file. - * - * @return string - * The path of the installed package, relative to composer.json. - */ - protected function getPackagePath(array $package): string { - return 'vendor' . DIRECTORY_SEPARATOR . $package['name']; - } - - /** - * Deletes all copied packages. - * - * @see ::copyPackage() - */ - protected function deleteCopiedPackages(): void { - (new SymfonyFilesystem())->remove($this->copiedPackages); - } - - /** - * Copies a package's entire directory to another location. - * - * The copies' paths will be stored so that they can be easily deleted by - * ::deleteCopiedPackages(). - * - * @param string $source_dir - * The path of the package directory to copy. - * @param string|null $destination_dir - * (optional) The directory to which the package should be copied. Will be - * suffixed with a random string to ensure uniqueness. If not given, the - * system temporary directory will be used. - * - * @return string - * The path of the temporary copy. - * - * @see ::deleteCopiedPackages() - */ - protected function copyPackage(string $source_dir, string $destination_dir = NULL): string { - Assert::assertDirectoryExists($source_dir); - - if (empty($destination_dir)) { - $destination_dir = FileSystem::getOsTemporaryDirectory(); - Assert::assertNotEmpty($destination_dir); - $destination_dir .= DIRECTORY_SEPARATOR; - } - $destination_dir = uniqid($destination_dir); - Assert::assertDirectoryDoesNotExist($destination_dir); - - (new SymfonyFilesystem())->mirror($source_dir, $destination_dir); - array_push($this->copiedPackages, $destination_dir); - - return $destination_dir; - } - - /** - * Generates local path repositories for a set of installed packages. - * - * @param string $dir - * The directory which contains composer.lock. - * - * @return mixed[][] - * The local path repositories' configuration, for inclusion in a - * composer.json file. - */ - protected function getLocalPackageRepositories(string $dir): array { - $repositories = []; - - foreach ($this->getPackagesFromLockFile($dir) as $package) { - // Ensure the package directory is writable, since we'll need to make a - // few changes to it. - $path = $dir . DIRECTORY_SEPARATOR . $this->getPackagePath($package); - Assert::assertIsWritable($path); - $composer = $path . DIRECTORY_SEPARATOR . 'composer.json'; - - // Overwrite the composer.json with the fully resolved package information - // from the lock file. - // @todo Back up composer.json before overwriting it? - $this->writeJson($composer, $package); - - $name = $package['name']; - $repositories[$name] = $this->createPathRepository($path); - } - return $repositories; - } - - /** - * Defines a local path repository for a given path. - * - * @param string $path - * The path of the repository. - * - * @return array - * The local path repository definition. - */ - protected function createPathRepository(string $path): array { - return [ - 'type' => 'path', - 'url' => $path, - 'options' => [ - 'symlink' => FALSE, - ], - ]; - } - - /** - * Alters a package's composer.json file. - * - * @param string $package_dir - * The package directory. - * @param array $changes - * The changes to merge into composer.json. - */ - protected function alterPackage(string $package_dir, array $changes): void { - $composer = $package_dir . DIRECTORY_SEPARATOR . 'composer.json'; - $data = $this->readJson($composer); - $data = NestedArray::mergeDeep($data, $changes); - $this->writeJson($composer, $data); - } - - /** - * Reads all package information from a composer.lock file. - * - * @param string $dir - * The directory which contains the lock file. - * - * @return mixed[][] - * All package information (including dev packages) from the lock file. - */ - private function getPackagesFromLockFile(string $dir): array { - $lock = $this->readJson($dir . DIRECTORY_SEPARATOR . 'composer.lock'); - return array_merge($lock['packages'], $lock['packages-dev']); - } - -} diff --git a/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php b/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php deleted file mode 100644 index f1c1919b9b28..000000000000 --- a/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php +++ /dev/null @@ -1,57 +0,0 @@ -<?php - -namespace Drupal\Tests\auto_updates\Traits; - -use PHPUnit\Framework\Assert; - -/** - * Provides methods for manipulating site settings. - */ -trait SettingsTrait { - - /** - * Appends some PHP code to settings.php. - * - * @param string $php - * The PHP code to append to settings.php. - * @param string $drupal_root - * The path of the Drupal root. - * @param string $site - * (optional) The name of the site whose settings.php should be amended. - * Defaults to 'default'. - */ - protected function addSettings(string $php, string $drupal_root, string $site = 'default'): void { - $settings = $this->makeSettingsWritable($drupal_root, $site); - $settings = fopen($settings, 'a'); - Assert::assertIsResource($settings); - Assert::assertIsInt(fwrite($settings, $php)); - Assert::assertTrue(fclose($settings)); - } - - /** - * Ensures that settings.php is writable. - * - * @param string $drupal_root - * The path of the Drupal root. - * @param string $site - * (optional) The name of the site whose settings should be made writable. - * Defaults to 'default'. - * - * @return string - * The path to settings.php for the specified site. - */ - private function makeSettingsWritable(string $drupal_root, string $site = 'default'): string { - $settings = implode(DIRECTORY_SEPARATOR, [ - $drupal_root, - 'sites', - $site, - 'settings.php', - ]); - chmod(dirname($settings), 0744); - chmod($settings, 0744); - Assert::assertIsWritable($settings); - - return $settings; - } - -} diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml index d2b01442f16a..f5e04442d0d4 100644 --- a/core/modules/package_manager/package_manager.services.yml +++ b/core/modules/package_manager/package_manager.services.yml @@ -4,8 +4,6 @@ services: class: Symfony\Component\Filesystem\Filesystem package_manager.symfony_executable_finder: class: Symfony\Component\Process\ExecutableFinder - package_manager.symfony_finder: - class: Symfony\Component\Finder\Finder # Basic infrastructure services. package_manager.process_factory: @@ -45,8 +43,6 @@ services: class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\PhpFileSyncer arguments: - '@package_manager.file_system' - - '@package_manager.symfony_finder' - - '@package_manager.symfony_finder' package_manager.file_syncer.factory: class: Drupal\package_manager\FileSyncerFactory arguments: @@ -55,7 +51,7 @@ services: - '@package_manager.file_syncer.rsync' - '@config.factory' package_manager.file_syncer: - class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface + class: PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface factory: ['@package_manager.file_syncer.factory', 'create'] # Domain services. @@ -127,10 +123,10 @@ services: package_manager.excluded_paths_subscriber: class: Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber arguments: - - '%app.root%' - '%site.path%' - - '@file_system' + - '@package_manager.symfony_file_system' - '@stream_wrapper_manager' - '@database' + - '@package_manager.path_locator' tags: - { name: event_subscriber } diff --git a/core/modules/package_manager/src/Event/ExcludedPathsTrait.php b/core/modules/package_manager/src/Event/ExcludedPathsTrait.php index 3648446b80f2..fc7e6904402a 100644 --- a/core/modules/package_manager/src/Event/ExcludedPathsTrait.php +++ b/core/modules/package_manager/src/Event/ExcludedPathsTrait.php @@ -15,12 +15,23 @@ trait ExcludedPathsTrait { protected $excludedPaths = []; /** - * Adds an absolute path to exclude from the current operation. + * Adds a path to exclude from the current operation. * - * @todo This should only accept paths relative to the active directory. + * If called on an instance of \Drupal\package_manager\Event\PreCreateEvent, + * excluded paths will not be copied into the staging area when the stage is + * created. If called on an instance of + * \Drupal\package_manager\Event\PreApplyEvent, excluded paths will not be + * deleted from the active directory when staged changes are applied. So, + * to ensure that a given path is never staged, but also preserved in the + * active directory, it should be passed to this method on both PreCreateEvent + * and PreApplyEvent. See + * \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber for an + * example. * * @param string $path - * The path to exclude. + * The path to exclude, relative to the project root. + * + * @see \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber */ public function excludePath(string $path): void { $this->excludedPaths[] = $path; diff --git a/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php b/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php index a4df9a359155..f91e85293032 100644 --- a/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php +++ b/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php @@ -7,21 +7,21 @@ use Drupal\Core\Extension\ExtensionVersion; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\StringTranslation\TranslationInterface; -use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; +use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface; +use PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface; use PhpTuf\ComposerStager\Exception\ExceptionInterface; -use PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface; /** * Validates that the Composer executable can be found in the correct version. */ -class ComposerExecutableValidator implements PreOperationStageValidatorInterface, ProcessOutputCallbackInterface { +class ComposerExecutableValidator implements PreOperationStageValidatorInterface, OutputCallbackInterface { use StringTranslationTrait; /** * The Composer runner. * - * @var \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface + * @var \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface */ protected $composer; @@ -35,7 +35,7 @@ class ComposerExecutableValidator implements PreOperationStageValidatorInterface /** * Constructs a ComposerExecutableValidator object. * - * @param \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface $composer + * @param \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface $composer * The Composer runner. * @param \Drupal\Core\StringTranslation\TranslationInterface $translation * The translation service. diff --git a/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php b/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php index 4663284c7963..b65ff7a70921 100644 --- a/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php +++ b/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php @@ -3,25 +3,20 @@ namespace Drupal\package_manager\EventSubscriber; use Drupal\Core\Database\Connection; -use Drupal\Core\File\FileSystemInterface; use Drupal\Core\StreamWrapper\LocalStream; use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface; use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\StageEvent; +use Drupal\package_manager\PathLocator; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Filesystem\Filesystem; /** * Defines an event subscriber to exclude certain paths from staging areas. */ class ExcludedPathsSubscriber implements EventSubscriberInterface { - /** - * The Drupal root. - * - * @var string - */ - protected $appRoot; - /** * The current site path, relative to the Drupal root. * @@ -30,9 +25,9 @@ class ExcludedPathsSubscriber implements EventSubscriberInterface { protected $sitePath; /** - * The file system service. + * The Symfony file system service. * - * @var \Drupal\Core\File\FileSystemInterface + * @var \Symfony\Component\Filesystem\Filesystem */ protected $fileSystem; @@ -50,91 +45,157 @@ class ExcludedPathsSubscriber implements EventSubscriberInterface { */ protected $database; + /** + * The path locator service. + * + * @var \Drupal\package_manager\PathLocator + */ + protected $pathLocator; + /** * Constructs an ExcludedPathsSubscriber. * - * @param string $app_root - * The Drupal root. * @param string $site_path * The current site path, relative to the Drupal root. - * @param \Drupal\Core\File\FileSystemInterface $file_system - * The file system service. + * @param \Symfony\Component\Filesystem\Filesystem $file_system + * The Symfony file system service. * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager * The stream wrapper manager service. * @param \Drupal\Core\Database\Connection $database * The database connection. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. */ - public function __construct(string $app_root, string $site_path, FileSystemInterface $file_system, StreamWrapperManagerInterface $stream_wrapper_manager, Connection $database) { - $this->appRoot = $app_root; + public function __construct(string $site_path, Filesystem $file_system, StreamWrapperManagerInterface $stream_wrapper_manager, Connection $database, PathLocator $path_locator) { $this->sitePath = $site_path; $this->fileSystem = $file_system; $this->streamWrapperManager = $stream_wrapper_manager; $this->database = $database; + $this->pathLocator = $path_locator; } /** - * Reacts before staged changes are committed the active directory. + * Flags paths to be excluded, relative to the web root. * - * @param \Drupal\package_manager\Event\PreApplyEvent $event + * This should only be used for paths that, if they exist at all, are + * *guaranteed* to exist within the web root. + * + * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event * The event object. + * @param string[] $paths + * The paths to exclude. These should be relative to the web root, and will + * be made relative to the project root. */ - public function preApply(PreApplyEvent $event): void { - // Don't copy anything from the staging area's sites/default. - // @todo Make this a lot smarter in https://www.drupal.org/i/3228955. - $event->excludePath('sites/default'); + protected function excludeInWebRoot(StageEvent $event, array $paths): void { + $web_root = $this->pathLocator->getWebRoot(); + if ($web_root) { + $web_root .= '/'; + } - // If the core-vendor-hardening plugin (used in the legacy-project template) - // is present, it may have written a web.config file into the vendor - // directory. We don't want to copy that. - $event->excludePath('web.config'); + foreach ($paths as $path) { + // Make the path relative to the project root by prefixing the web root. + $event->excludePath($web_root . $path); + } } /** - * Excludes paths from a staging area before it is created. + * Flags paths to be excluded, relative to the project root. * - * @param \Drupal\package_manager\Event\PreCreateEvent $event + * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event * The event object. + * @param string[] $paths + * The paths to exclude. Absolute paths will be made relative to the project + * root; relative paths will be assumed to already be relative to the + * project root, and excluded as given. */ - public function preCreate(PreCreateEvent $event): void { - // Automated test site directories should never be staged. - $event->excludePath('sites/simpletest'); - - // Windows server configuration files, like web.config, should never be - // staged either. (These can be written in the vendor directory by the - // core-vendor-hardening plugin, which is used in the drupal/legacy-project - // template.) - $event->excludePath('web.config'); - - if ($public = $this->getFilesPath('public')) { - $event->excludePath($public); + protected function excludeInProjectRoot(StageEvent $event, array $paths): void { + $project_root = $this->pathLocator->getProjectRoot(); + + foreach ($paths as $path) { + // Make absolute paths relative to the project root. + $path = str_replace($project_root, NULL, $path); + $path = ltrim($path, '/'); + $event->excludePath($path); } - if ($private = $this->getFilesPath('private')) { - $event->excludePath($private); + } + + /** + * Excludes common paths from staging operations. + * + * @param \Drupal\package_manager\Event\PreApplyEvent|\Drupal\package_manager\Event\PreCreateEvent $event + * The event object. + * + * @see \Drupal\package_manager\Event\ExcludedPathsTrait::excludePath() + */ + public function ignoreCommonPaths(StageEvent $event): void { + // Compile two lists of paths to exclude: paths that are relative to the + // project root, and paths that are relative to the web root. + $web = $project = []; + + // Always ignore automated test directories. If they exist, they will be in + // the web root. + $web[] = 'sites/simpletest'; + + // If the core-vendor-hardening plugin (used in the legacy-project template) + // is present, it may have written security hardening files in the vendor + // directory. They should always be ignored. + $vendor_dir = $this->pathLocator->getVendorDirectory(); + $project[] = $vendor_dir . '/web.config'; + $project[] = $vendor_dir . '/.htaccess'; + + // Ignore public and private files. These paths could be either absolute or + // relative, depending on site settings. If they are absolute, treat them + // as relative to the project root. Otherwise, treat them as relative to + // the web root. + $files = array_filter([ + $this->getFilesPath('public'), + $this->getFilesPath('private'), + ]); + foreach ($files as $path) { + if ($this->fileSystem->isAbsolutePath($path)) { + $project[] = $path; + } + else { + $web[] = $path; + } } - // Exclude site-specific settings files. + // Ignore site-specific settings files, which are always in the web root. $settings_files = [ 'settings.php', 'settings.local.php', 'services.yml', ]; - $default_site = 'sites' . DIRECTORY_SEPARATOR . 'default'; - foreach ($settings_files as $settings_file) { - $event->excludePath($this->sitePath . DIRECTORY_SEPARATOR . $settings_file); - $event->excludePath($default_site . DIRECTORY_SEPARATOR . $settings_file); + $web[] = $this->sitePath . '/' . $settings_file; + $web[] = 'sites/default/' . $settings_file; } // If the database is SQLite, it might be located in the active directory - // and we should not stage it. + // and we should ignore it. Always treat it as relative to the project root. if ($this->database->driver() === 'sqlite') { $options = $this->database->getConnectionOptions(); - $database = str_replace($this->appRoot, NULL, $options['database']); - $database = ltrim($database, '/'); - $event->excludePath($database); - $event->excludePath("$database-shm"); - $event->excludePath("$database-wal"); + $project[] = $options['database']; + $project[] = $options['database'] . '-shm'; + $project[] = $options['database'] . '-wal'; } + + $this->excludeInWebRoot($event, $web); + $this->excludeInProjectRoot($event, $project); + } + + /** + * Reacts before staged changes are committed the active directory. + * + * @param \Drupal\package_manager\Event\PreApplyEvent $event + * The event object. + */ + public function preApply(PreApplyEvent $event): void { + // Don't copy anything from the staging area's sites/default. + // @todo Make this a lot smarter in https://www.drupal.org/i/3228955. + $this->excludeInWebRoot($event, ['sites/default']); + + $this->ignoreCommonPaths($event); } /** @@ -165,7 +226,7 @@ private function getFilesPath(string $scheme): ?string { */ public static function getSubscribedEvents() { return [ - PreCreateEvent::class => 'preCreate', + PreCreateEvent::class => 'ignoreCommonPaths', PreApplyEvent::class => 'preApply', ]; } diff --git a/core/modules/package_manager/src/FileSyncerFactory.php b/core/modules/package_manager/src/FileSyncerFactory.php index 727620a24857..ee96aa1e579c 100644 --- a/core/modules/package_manager/src/FileSyncerFactory.php +++ b/core/modules/package_manager/src/FileSyncerFactory.php @@ -3,9 +3,9 @@ namespace Drupal\package_manager; use Drupal\Core\Config\ConfigFactoryInterface; -use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactoryInterface; +use PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerFactoryInterface; +use PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface; use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactory as StagerFileSyncerFactory; -use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface; use Symfony\Component\Process\ExecutableFinder; /** @@ -16,21 +16,21 @@ class FileSyncerFactory implements FileSyncerFactoryInterface { /** * The decorated file syncer factory. * - * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactoryInterface + * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerFactoryInterface */ protected $decorated; /** * The PHP file syncer service. * - * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface + * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface */ protected $phpFileSyncer; /** * The rsync file syncer service. * - * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface + * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface */ protected $rsyncFileSyncer; @@ -46,9 +46,9 @@ class FileSyncerFactory implements FileSyncerFactoryInterface { * * @param \Symfony\Component\Process\ExecutableFinder $executable_finder * The Symfony executable finder. - * @param \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface $php_file_syncer + * @param \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface $php_file_syncer * The PHP file syncer service. - * @param \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface $rsync_file_syncer + * @param \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface $rsync_file_syncer * The rsync file syncer service. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The config factory service. diff --git a/core/modules/package_manager/src/Stage.php b/core/modules/package_manager/src/Stage.php index 6548db10cf65..4432089cd00f 100644 --- a/core/modules/package_manager/src/Stage.php +++ b/core/modules/package_manager/src/Stage.php @@ -238,14 +238,15 @@ public function create(): string { public function require(array $constraints, bool $dev = FALSE): void { $this->checkOwnership(); - $command = array_merge(['require'], $constraints); - $command[] = '--update-with-all-dependencies'; + $command = array_merge(['require', '--no-update'], $constraints); if ($dev) { $command[] = '--dev'; } $this->dispatch(new PreRequireEvent($this)); - $this->stager->stage($command, $this->getStageDirectory()); + $dir = $this->getStageDirectory(); + $this->stager->stage($command, $dir); + $this->stager->stage(['update', '--with-all-dependencies'], $dir); $this->dispatch(new PostRequireEvent($this)); } 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 000000000000..e11552b41d40 --- /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/modules/package_manager_bypass/src/Beginner.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php index 742d38bd85dd..e9ff25509f81 100644 --- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php @@ -3,7 +3,7 @@ namespace Drupal\package_manager_bypass; use PhpTuf\ComposerStager\Domain\BeginnerInterface; -use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; +use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface; /** * Defines an update beginner which doesn't do anything. @@ -13,7 +13,7 @@ class Beginner extends InvocationRecorderBase implements BeginnerInterface { /** * {@inheritdoc} */ - public function begin(string $activeDir, string $stagingDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + public function begin(string $activeDir, string $stagingDir, ?array $exclusions = [], ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions); } diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php index 3518237b5b1b..2feb0d0f70c1 100644 --- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php @@ -3,7 +3,7 @@ namespace Drupal\package_manager_bypass; use PhpTuf\ComposerStager\Domain\CommitterInterface; -use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; +use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface; /** * Defines an update committer which doesn't do any actual committing. @@ -30,7 +30,7 @@ public function __construct(CommitterInterface $decorated) { /** * {@inheritdoc} */ - public function commit(string $stagingDir, string $activeDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + public function commit(string $stagingDir, string $activeDir, ?array $exclusions = [], ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions); } diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php index bb93e47271db..237eccab38c1 100644 --- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php +++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php @@ -2,7 +2,7 @@ namespace Drupal\package_manager_bypass; -use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface; +use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface; use PhpTuf\ComposerStager\Domain\StagerInterface; /** @@ -13,7 +13,7 @@ class Stager extends InvocationRecorderBase implements StagerInterface { /** * {@inheritdoc} */ - public function stage(array $composerCommand, string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { + public function stage(array $composerCommand, string $stagingDir, ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void { $this->saveInvocationArguments($composerCommand, $stagingDir); } diff --git a/core/modules/package_manager/tests/src/Functional/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Functional/ExcludedPathsTest.php deleted file mode 100644 index 29204c9d6566..000000000000 --- a/core/modules/package_manager/tests/src/Functional/ExcludedPathsTest.php +++ /dev/null @@ -1,155 +0,0 @@ -<?php - -namespace Drupal\Tests\package_manager\Functional; - -use Drupal\Core\Database\Driver\sqlite\Connection; -use Drupal\Core\Site\Settings; -use Drupal\package_manager\PathLocator; -use Drupal\package_manager\Stage; -use Drupal\Tests\BrowserTestBase; - -/** - * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber - * - * @group package_manager - */ -class ExcludedPathsTest extends BrowserTestBase { - - /** - * {@inheritdoc} - */ - protected $defaultTheme = 'stark'; - - /** - * {@inheritdoc} - */ - protected static $modules = [ - 'package_manager', - 'package_manager_bypass', - ]; - - /** - * {@inheritdoc} - */ - protected function prepareSettings() { - parent::prepareSettings(); - - // Disable the filesystem permissions validator, since we cannot guarantee - // that the current code base will be writable in all testing situations. - // We test this validator functionally in Automatic Updates' build tests, - // since those do give us control over the filesystem permissions. - // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError() - // @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest - $this->writeSettings([ - 'settings' => [ - 'package_manager_bypass_stager' => (object) [ - 'value' => FALSE, - 'required' => TRUE, - ], - 'package_manager_bypass_validators' => (object) [ - 'value' => ['package_manager.validator.file_system'], - 'required' => TRUE, - ], - ], - ]); - } - - /** - * Tests that certain paths are excluded from staging areas. - */ - public function testExcludedPaths(): void { - $active_dir = __DIR__ . '/../../fixtures/fake_site'; - - $path_locator = $this->prophesize(PathLocator::class); - $path_locator->getActiveDirectory()->willReturn($active_dir); - - $site_path = 'sites/example.com'; - - // Ensure that we are using directories within the fake site fixture for - // public and private files. - $settings = Settings::getAll(); - $settings['file_public_path'] = "$site_path/files"; - $settings['file_private_path'] = 'private'; - new Settings($settings); - - /** @var \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber $subscriber */ - $subscriber = $this->container->get('package_manager.excluded_paths_subscriber'); - $reflector = new \ReflectionObject($subscriber); - $property = $reflector->getProperty('sitePath'); - $property->setAccessible(TRUE); - $property->setValue($subscriber, $site_path); - - // Mock a SQLite database connection to a file in the active directory. The - // file should not be staged. - $database = $this->prophesize(Connection::class); - $database->driver()->willReturn('sqlite'); - $database->getConnectionOptions()->willReturn([ - 'database' => $site_path . '/db.sqlite', - ]); - $property = $reflector->getProperty('database'); - $property->setAccessible(TRUE); - $property->setValue($subscriber, $database->reveal()); - - $stage = new class( - $path_locator->reveal(), - $this->container->get('package_manager.beginner'), - $this->container->get('package_manager.stager'), - $this->container->get('package_manager.committer'), - $this->container->get('file_system'), - $this->container->get('event_dispatcher'), - $this->container->get('tempstore.shared'), - ) extends Stage { - - /** - * The directory where staging areas will be created. - * - * @var string - */ - public static $stagingRoot; - - /** - * {@inheritdoc} - */ - protected static function getStagingRoot(): string { - return static::$stagingRoot; - } - - }; - $stage::$stagingRoot = $this->siteDirectory . '/stage'; - $stage_dir = $stage::$stagingRoot . DIRECTORY_SEPARATOR . $stage->create(); - - $this->assertDirectoryExists($stage_dir); - $this->assertDirectoryDoesNotExist("$stage_dir/sites/simpletest"); - $this->assertFileDoesNotExist("$stage_dir/vendor/web.config"); - $this->assertDirectoryDoesNotExist("$stage_dir/$site_path/files"); - $this->assertDirectoryDoesNotExist("$stage_dir/private"); - $this->assertFileDoesNotExist("$stage_dir/$site_path/settings.php"); - $this->assertFileDoesNotExist("$stage_dir/$site_path/settings.local.php"); - $this->assertFileDoesNotExist("$stage_dir/$site_path/services.yml"); - // SQLite databases and their support files should never be staged. - $this->assertFileDoesNotExist("$stage_dir/$site_path/db.sqlite"); - $this->assertFileDoesNotExist("$stage_dir/$site_path/db.sqlite-shm"); - $this->assertFileDoesNotExist("$stage_dir/$site_path/db.sqlite-wal"); - // Default site-specific settings files should never be staged. - $this->assertFileDoesNotExist("$stage_dir/sites/default/settings.php"); - $this->assertFileDoesNotExist("$stage_dir/sites/default/settings.local.php"); - $this->assertFileDoesNotExist("$stage_dir/sites/default/services.yml"); - // A non-excluded file in the default site directory should be staged. - $this->assertFileExists("$stage_dir/sites/default/stage.txt"); - - $files = [ - 'sites/default/no-copy.txt', - 'web.config', - ]; - foreach ($files as $file) { - $file = "$stage_dir/$file"; - touch($file); - $this->assertFileExists($file); - } - $stage->apply(); - foreach ($files as $file) { - $this->assertFileDoesNotExist("$active_dir/$file"); - } - } - -} diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php index 8905f788edda..bc57b4fa75a7 100644 --- a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php @@ -112,8 +112,8 @@ public function testComposerVersionValidation(string $reported_version, array $e // Mock the output of `composer --version`, will be passed to the validator, // which is itself a callback function that gets called repeatedly as // Composer produces output. - /** @var \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface|\Prophecy\Prophecy\ObjectProphecy $runner */ - $runner = $this->prophesize('\PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface'); + /** @var \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface|\Prophecy\Prophecy\ObjectProphecy $runner */ + $runner = $this->prophesize('\PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface'); $runner->run(['--version'], Argument::type(ComposerExecutableValidator::class)) // Whatever is passed to ::run() will be passed to this mock callback in diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php index fae703f6e0ef..ff09f97428ae 100644 --- a/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php @@ -85,6 +85,7 @@ public function testSecureHttpValidation(string $contents, array $expected_resul $locator = $this->prophesize(PathLocator::class); $locator->getActiveDirectory()->willReturn($active_dir); $locator->getProjectRoot()->willReturn($active_dir); + $locator->getWebRoot()->willReturn(''); $locator->getVendorDirectory()->willReturn($active_dir); $this->container->set('package_manager.path_locator', $locator->reveal()); diff --git a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php index ea15bb086ef4..3195b51d3a14 100644 --- a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php @@ -168,6 +168,7 @@ public function providerDiskSpaceValidation(): array { public function testDiskSpaceValidation(bool $shared_disk, array $free_space, array $expected_results): void { $path_locator = $this->prophesize('\Drupal\package_manager\PathLocator'); $path_locator->getProjectRoot()->willReturn('root'); + $path_locator->getWebRoot()->willReturn(''); $path_locator->getActiveDirectory()->willReturn('root'); $path_locator->getVendorDirectory()->willReturn('vendor'); $this->container->set('package_manager.path_locator', $path_locator->reveal()); diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php index b44f7811d62c..0489ad421300 100644 --- a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php +++ b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php @@ -2,7 +2,7 @@ namespace Drupal\Tests\package_manager\Kernel; -use Drupal\Core\Database\Driver\sqlite\Connection; +use Drupal\Core\Database\Connection; use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber; @@ -83,15 +83,15 @@ public function testSqliteDatabaseExcluded(string $database, array $expected_exc $connection->getConnectionOptions()->willReturn(['database' => $database]); $subscriber = new ExcludedPathsSubscriber( - $this->getDrupalRoot(), 'sites/default', - $this->container->get('file_system'), + $this->container->get('package_manager.symfony_file_system'), $this->container->get('stream_wrapper_manager'), - $connection->reveal() + $connection->reveal(), + $this->container->get('package_manager.path_locator') ); $event = new PreCreateEvent($this->createStage()); - $subscriber->preCreate($event); + $subscriber->ignoreCommonPaths($event); // All of the expected exclusions should be flagged. $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths())); } diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php new file mode 100644 index 000000000000..b9a8eadbe36a --- /dev/null +++ b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php @@ -0,0 +1,227 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\Core\Database\Connection; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber; +use Drupal\package_manager\PathLocator; +use org\bovigo\vfs\vfsStream; + +/** + * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber + * + * @group package_manager + */ +class ExcludedPathsTest extends PackageManagerKernelTestBase { + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Ensure that any staging directories created by TestStage are created + // in the virtual file system. + TestStage::$stagingRoot = $this->vfsRoot->url(); + + // We need to rebuild the container after setting a private file path, since + // the private stream wrapper is only registered if this setting is set. + // @see \Drupal\Core\CoreServiceProvider::register() + $this->setSetting('file_private_path', 'private'); + $kernel = $this->container->get('kernel'); + $kernel->rebuildContainer(); + $this->container = $kernel->getContainer(); + } + + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + // Normally, package_manager_bypass will disable all the actual staging + // operations. In this case, we want to perform them so that we can be sure + // that files are staged as expected. + $this->setSetting('package_manager_bypass_stager', FALSE); + + $container->getDefinition('package_manager.excluded_paths_subscriber') + ->setClass(TestExcludedPathsSubscriber::class); + + parent::register($container); + } + + /** + * {@inheritdoc} + */ + protected function disableValidators(ContainerBuilder $container): void { + parent::disableValidators($container); + + // Disable the disk space validator, since it tries to inspect the file + // system in ways that vfsStream doesn't support, like calling stat() and + // disk_free_space(). + $container->removeDefinition('package_manager.validator.disk_space'); + + // Disable the lock file and Composer settings validators, since in this + // test we have an imaginary file system without any Composer files. + $container->removeDefinition('package_manager.validator.lock_file'); + } + + /** + * Tests that certain paths are excluded from staging operations. + */ + public function testExcludedPaths(): void { + $site = [ + 'composer.json' => '{}', + 'private' => [ + 'ignore.txt' => 'This file should never be staged.', + ], + 'sites' => [ + 'default' => [ + 'services.yml' => <<<END +# This file should never be staged. +must_not_be: 'empty' +END, + 'settings.local.php' => <<<END +<?php + +/** + * @file + * This file should never be staged. + */ +END, + 'settings.php' => <<<END +<?php + +/** + * @file + * This file should never be staged. + */ +END, + 'stage.txt' => 'This file should be staged.', + ], + 'example.com' => [ + 'files' => [ + 'ignore.txt' => 'This file should never be staged.', + ], + 'db.sqlite' => 'This file should never be staged.', + 'db.sqlite-shm' => 'This file should never be staged.', + 'db.sqlite-wal' => 'This file should never be staged.', + 'services.yml' => <<<END +# This file should never be staged. +key: "value" +END, + 'settings.local.php' => <<<END +<?php + +/** + * @file + * This file should never be staged. + */ +END, + 'settings.php' => <<<END +<?php + +/** + * @file + * This file should never be staged. + */ +END, + ], + 'simpletest' => [ + 'ignore.txt' => 'This file should never be staged.', + ], + ], + 'vendor' => [ + '.htaccess' => '# This file should never be staged.', + 'web.config' => 'This file should never be staged.', + ], + ]; + vfsStream::create(['active' => $site], $this->vfsRoot); + + $active_dir = $this->vfsRoot->getChild('active')->url(); + + $path_locator = $this->prophesize(PathLocator::class); + $path_locator->getActiveDirectory()->willReturn($active_dir); + $path_locator->getProjectRoot()->willReturn($active_dir); + $path_locator->getWebRoot()->willReturn(''); + $path_locator->getVendorDirectory()->willReturn("$active_dir/vendor"); + $this->container->set('package_manager.path_locator', $path_locator->reveal()); + + $site_path = 'sites/example.com'; + // Ensure that we are using directories within the fake site fixture for + // public and private files. + $this->setSetting('file_public_path', "$site_path/files"); + + /** @var \Drupal\Tests\package_manager\Kernel\TestExcludedPathsSubscriber $subscriber */ + $subscriber = $this->container->get('package_manager.excluded_paths_subscriber'); + $subscriber->sitePath = $site_path; + + // Mock a SQLite database connection to a file in the active directory. The + // file should not be staged. + $database = $this->prophesize(Connection::class); + $database->driver()->willReturn('sqlite'); + $database->getConnectionOptions()->willReturn([ + 'database' => $site_path . '/db.sqlite', + ]); + $subscriber->database = $database->reveal(); + + $stage = $this->createStage(); + $stage->create(); + $stage_dir = $stage->getStageDirectory(); + + $ignore = [ + 'sites/simpletest', + 'vendor/.htaccess', + 'vendor/web.config', + "$site_path/files/ignore.txt", + 'private/ignore.txt', + "$site_path/settings.php", + "$site_path/settings.local.php", + "$site_path/services.yml", + // SQLite databases and their support files should always be ignored. + "$site_path/db.sqlite", + "$site_path/db.sqlite-shm", + "$site_path/db.sqlite-wal", + // Default site-specific settings files should be ignored. + 'sites/default/settings.php', + 'sites/default/settings.local.php', + 'sites/default/services.yml', + ]; + foreach ($ignore 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"); + + // A new file added to the staging area in an excluded directory, should not + // be copied to the active directory. + $file = "$stage_dir/sites/default/no-copy.txt"; + touch($file); + $this->assertFileExists($file); + $stage->apply(); + $this->assertFileDoesNotExist("$active_dir/sites/default/no-copy.txt"); + + // The ignored files should still be in the active directory. + foreach ($ignore as $path) { + $this->assertFileExists("$active_dir/$path"); + } + } + +} + +/** + * A test-only implementation of the excluded path event subscriber. + */ +class TestExcludedPathsSubscriber extends ExcludedPathsSubscriber { + + /** + * {@inheritdoc} + */ + public $sitePath; + + /** + * {@inheritdoc} + */ + public $database; + +} diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index 414d35124229..e6e7b7504c06 100644 --- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -25,6 +25,14 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { 'package_manager_bypass', ]; + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installConfig('package_manager'); + } + /** * {@inheritdoc} */ @@ -118,6 +126,20 @@ protected function registerPostUpdateFunctions(): void { */ class TestStage extends Stage { + /** + * The directory where staging areas will be created. + * + * @var string + */ + public static $stagingRoot; + + /** + * {@inheritdoc} + */ + protected static function getStagingRoot(): string { + return static::$stagingRoot ?: parent::getStagingRoot(); + } + /** * {@inheritdoc} */ diff --git a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php index d6e504ea138c..21cf1b200774 100644 --- a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php +++ b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php @@ -113,6 +113,7 @@ public function testWritable(int $root_permissions, int $vendor_permissions, arr $path_locator = $this->prophesize(PathLocator::class); $path_locator->getActiveDirectory()->willReturn($root->url()); + $path_locator->getProjectRoot()->willReturn($root->url()); $path_locator->getWebRoot()->willReturn(''); $path_locator->getVendorDirectory()->willReturn($vendor->url()); $this->container->set('package_manager.path_locator', $path_locator->reveal()); -- GitLab