Skip to content
Snippets Groups Projects
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
No related branches found
No related tags found
1 merge request!145Issue #3254166: Build tests should not modify dependencies' composer.json files
......@@ -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();
}
/**
* 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 @@ 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 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']);
}
}
<?php
namespace Drupal\Tests\automatic_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, automatic_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/automatic_updates from
// drupal/core-recommended.
$this->runComposer('composer remove --no-update drupal/automatic_updates', 'composer/Metapackage/CoreRecommended');
}
/**
* 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;
}
}
......@@ -2,22 +2,12 @@
namespace Drupal\Tests\automatic_updates\Build;
use Drupal\BuildTests\QuickStart\QuickStartTestBase;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
use Drupal\Tests\automatic_updates\Traits\LocalPackagesTrait;
use Drupal\Tests\automatic_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,52 @@ abstract class UpdateTestBase extends QuickStartTestBase {
/**
* {@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.automatic_updates path ' . __DIR__ . '/../../..', $dir);
$this->assertStringNotContainsString('Symlinking', $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require "drupal/automatic_updates:@dev"', $dir));
// 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([
'automatic_updates',
'automatic_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,158 +97,12 @@ END;
// 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/automatic_updates'] = $this->createPathRepository(__DIR__ . '/../../..');
// Use whatever the current branch of automatic_updates is.
$data['require']['drupal/automatic_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);
}
/**
......
<?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));
}
}
<?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']);
}
}
<?php
namespace Drupal\Tests\automatic_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;
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment