Commit da33c349 authored by Adam G-H's avatar Adam G-H
Browse files

Issue #3254166 by phenaproxima: Build tests should not modify dependencies' composer.json files

parent 2cd1df2e
Loading
Loading
Loading
Loading
+113 −177
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@

namespace Drupal\Tests\automatic_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([
      'automatic_updates',
      'automatic_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();
  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);
  }

  /**
   * 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);
  }

  /**
   * 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 @@ class CoreUpdateTest extends UpdateTestBase {
   * @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 @@ class CoreUpdateTest extends UpdateTestBase {
   * @dataProvider providerTemplate
   */
  public function testUi(string $template): void {
    $this->createTestSite($template);
    $this->createTestProject($template);

    $mink = $this->getMink();
    $session = $mink->getSession();
@@ -207,7 +97,7 @@ class CoreUpdateTest extends UpdateTestBase {

    // 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 @@ class CoreUpdateTest extends UpdateTestBase {
    $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 @@ class CoreUpdateTest extends UpdateTestBase {
   * @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 @@ class CoreUpdateTest extends UpdateTestBase {
  }

  /**
   * 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 assertUpdateSuccessful(): void {
  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(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']);
  }

}
+285 −0

File added.

Preview size limit exceeded, changes collapsed.

+43 −192

File changed.

Preview size limit exceeded, changes collapsed.

tests/src/Traits/JsonTrait.php

deleted100644 → 0
+0 −41
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\automatic_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));
  }

}
+0 −163
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\automatic_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']);
  }

}
Loading