diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index 61b9e56e5294e73963d683c7f0a8b7ac4709b6ac..8f486c9b19c134dc57e23f6d1b19446947210759 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -108,3 +108,12 @@ services: class: Drupal\automatic_updates\EventSubscriber\CronOverride tags: - { name: config.factory.override } + automatic_updates.update: + class: Drupal\automatic_updates\Services\InPlaceUpdate + arguments: + - '@logger.channel.automatic_updates' + - '@plugin.manager.archiver' + - '@config.factory' + - '@file_system' + - '@http_client' + - '@automatic_updates.drupal_finder' diff --git a/composer.json b/composer.json index 58ff78dd1076e5b99a75c16e6dee556fb1abc4dc..96dd1871acc2fd313a3d37db6bdd238574cb4ca0 100644 --- a/composer.json +++ b/composer.json @@ -12,9 +12,10 @@ }, "require": { "ext-json": "*", + "ext-zip": "*", "composer/semver": "^1.0", "ocramius/package-versions": "^1.4", - "webflo/drupal-finder": "^1.1" + "webflo/drupal-finder": "^1.2" }, "require-dev": { "drupal/ctools": "3.2.0" diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml index 0e900e9b0a50be2cbf66d9d63ec9da57586ec076..a5dce8b4eb763e7bf54c86976964afdb4af73122 100644 --- a/config/install/automatic_updates.settings.yml +++ b/config/install/automatic_updates.settings.yml @@ -5,3 +5,4 @@ check_frequency: 43200 enable_readiness_checks: true hashes_uri: 'https://updates.drupal.org/release-hashes' ignored_paths: "modules/custom/*\nthemes/custom/*\nprofiles/custom/*" +download_uri: 'https://www.drupal.org/in-place-updates' diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml index 0a45a08a0e950b49f5fef51eccf0fc87c5a7487a..88a2ed4430fe685fdab2685223291891a646f0aa 100644 --- a/config/schema/automatic_updates.schema.yml +++ b/config/schema/automatic_updates.schema.yml @@ -23,3 +23,6 @@ automatic_updates.settings: ignored_paths: type: string label: 'List of files paths to ignore when running readiness checks' + download_uri: + type: string + label: 'URI for downloading in-place update assets' diff --git a/drupalci.yml b/drupalci.yml index 8fa0ea2178d7c515b6b6c57373672a8fe88dc23d..82376884f72655b143fd0f32d748633bdc96d537 100644 --- a/drupalci.yml +++ b/drupalci.yml @@ -4,19 +4,23 @@ build: assessment: validate_codebase: phplint: - container_composer: phpcs: # phpcs will use core's specified version of Coder. sniff-all-files: true halt-on-fail: true testing: container_command: - commands: "cd ${SOURCE_DIR} && sudo -u www-data composer require drupal/ctools:3.2.0 --prefer-source --optimize-autoloader" + commands: + - cd ${SOURCE_DIR} && sudo -u www-data composer require drupal/ctools:3.2.0 --prefer-source --optimize-autoloader + - cd ${SOURCE_DIR} && sudo -u www-data curl https://www.drupal.org/files/issues/2019-10-07/3085728.patch | git apply - + - cd ${SOURCE_DIR} && sudo -u www-data curl https://www.drupal.org/files/issues/2019-10-07/3086373.patch | git apply - + # run_tests task is executed several times in order of performance speeds. # halt-on-fail can be set on the run_tests tasks in order to fail fast. # suppress-deprecations is false in order to be alerted to usages of # deprecated code. run_tests.standard: - types: 'Simpletest,PHPUnit-Unit,PHPUnit-Kernel,PHPUnit-Functional' + types: 'PHPUnit-Unit,PHPUnit-Kernel,PHPUnit-Functional,PHPUnit-Build' testgroups: '--all' suppress-deprecations: false + halt-on-fail: false diff --git a/src/Services/InPlaceUpdate.php b/src/Services/InPlaceUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..bcb0430a98861d5614c933c6f9d5baca8544b141 --- /dev/null +++ b/src/Services/InPlaceUpdate.php @@ -0,0 +1,451 @@ +<?php + +namespace Drupal\automatic_updates\Services; + +use Drupal\Core\Archiver\ArchiverInterface; +use Drupal\Core\Archiver\ArchiverManager; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\File\Exception\FileException; +use Drupal\Core\File\FileSystemInterface; +use Drupal\Core\Url; +use DrupalFinder\DrupalFinder; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\RequestException; +use Psr\Log\LoggerInterface; + +/** + * Class to apply in-place updates. + */ +class InPlaceUpdate implements UpdateInterface { + + /** + * The manifest file that lists all file deletions. + */ + const DELETION_MANIFEST = 'DELETION_MANIFEST.txt'; + + /** + * The checksum file with hashes of archive file contents. + */ + const CHECKSUM_LIST = 'checksumlist.csig'; + + /** + * The directory inside the archive for file additions and modifications. + */ + const ARCHIVE_DIRECTORY = 'files/'; + + /** + * The logger. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * The archive manager. + * + * @var \Drupal\Core\Archiver\ArchiverManager + */ + protected $archiveManager; + + /** + * The config factory. + * + * @var \Drupal\Core\Config\ConfigFactoryInterface + */ + protected $configFactory; + + /** + * The file system service. + * + * @var \Drupal\Core\File\FileSystemInterface + */ + protected $fileSystem; + + /** + * The HTTP client service. + * + * @var \GuzzleHttp\Client + */ + protected $httpClient; + + /** + * The root file path. + * + * @var string + */ + protected $rootPath; + + /** + * The vendor file path. + * + * @var string + */ + protected $vendorPath; + + /** + * The folder where files are backed up. + * + * @var string + */ + protected $backup; + + /** + * The temporary extract directory. + * + * @var string + */ + protected $tempDirectory; + + /** + * Constructs an InPlaceUpdate. + * + * @param \Psr\Log\LoggerInterface $logger + * The logger. + * @param \Drupal\Core\Archiver\ArchiverManager $archive_manager + * The archive manager. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory. + * @param \Drupal\Core\File\FileSystemInterface $file_system + * The filesystem service. + * @param \GuzzleHttp\ClientInterface $http_client + * The HTTP client service. + * @param \DrupalFinder\DrupalFinder $drupal_finder + * The Drupal finder service. + */ + public function __construct(LoggerInterface $logger, ArchiverManager $archive_manager, ConfigFactoryInterface $config_factory, FileSystemInterface $file_system, ClientInterface $http_client, DrupalFinder $drupal_finder) { + $this->logger = $logger; + $this->archiveManager = $archive_manager; + $this->configFactory = $config_factory; + $this->fileSystem = $file_system; + $this->httpClient = $http_client; + $drupal_finder->locateRoot(getcwd()); + $this->rootPath = $drupal_finder->getDrupalRoot(); + $this->vendorPath = rtrim($drupal_finder->getVendorDir(), '/\\') . DIRECTORY_SEPARATOR; + } + + /** + * {@inheritdoc} + */ + public function update($project_name, $project_type, $from_version, $to_version) { + $success = FALSE; + if ($project_name === 'drupal') { + $project_root = $this->rootPath; + } + else { + $project_root = drupal_get_path($project_type, $project_name); + } + if ($archive = $this->getArchive($project_name, $from_version, $to_version)) { + if ($this->backup($archive, $project_root)) { + $success = $this->processUpdate($archive, $project_root); + } + if (!$success) { + $this->rollback($project_root); + } + } + return $success; + } + + /** + * Get an archive with the quasi-patch contents. + * + * @param string $project_name + * The project name. + * @param string $from_version + * The current project version. + * @param string $to_version + * The desired next project version. + * + * @return \Drupal\Core\Archiver\ArchiverInterface|null + * The archive or NULL if download fails. + */ + protected function getArchive($project_name, $from_version, $to_version) { + $url = $this->buildUrl($project_name, $this->getQuasiPatchFileName($project_name, $from_version, $to_version)); + $destination = $this->fileSystem->realpath($this->fileSystem->getDestinationFilename("temporary://$project_name.zip", FileSystemInterface::EXISTS_RENAME)); + try { + $this->httpClient->get($url, ['sink' => $destination]); + /** @var \Drupal\Core\Archiver\ArchiverInterface $archive */ + return $this->archiveManager->getInstance(['filepath' => $destination]); + + } + catch (RequestException $exception) { + $this->logger->error('Update for @project to version @version failed for reason @message', [ + '@project' => $project_name, + '@version' => $to_version, + '@message' => $exception->getMessage(), + ]); + } + } + + /** + * Process update. + * + * @param \Drupal\Core\Archiver\ArchiverInterface $archive + * The archive. + * @param string $project_root + * The project root directory. + * + * @return bool + * Return TRUE if update succeeds, FALSE otherwise. + */ + protected function processUpdate(ArchiverInterface $archive, $project_root) { + $archive->extract($this->getTempDirectory()); + foreach ($this->getFilesList($this->getTempDirectory()) as $file) { + $file_real_path = $this->getFileRealPath($file); + $file_path = substr($file_real_path, strlen($this->getTempDirectory() . self::ARCHIVE_DIRECTORY)); + $project_real_path = $this->getProjectRealPath($file_path, $project_root); + try { + $directory = dirname($project_real_path); + $this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY); + $this->fileSystem->copy($file_real_path, $project_real_path, FileSystemInterface::EXISTS_REPLACE); + } + catch (FileException $exception) { + return FALSE; + } + } + foreach ($this->getDeletions() as $deletion) { + try { + $this->fileSystem->delete($this->getProjectRealPath($deletion, $project_root)); + } + catch (FileException $exception) { + return FALSE; + } + } + return TRUE; + } + + /** + * Backup before an update. + * + * @param \Drupal\Core\Archiver\ArchiverInterface $archive + * The archive. + * @param string $project_root + * The project root directory. + * + * @return bool + * Return TRUE if backup succeeds, FALSE otherwise. + */ + protected function backup(ArchiverInterface $archive, $project_root) { + $backup = $this->fileSystem->createFilename('automatic_updates-backup', 'temporary://'); + $this->fileSystem->prepareDirectory($backup); + $this->backup = $this->fileSystem->realpath($backup) . DIRECTORY_SEPARATOR; + if (!$this->backup) { + return FALSE; + } + foreach ($archive->listContents() as $file) { + // Ignore files that aren't in the files directory. + if (!$this->stripFileDirectoryPath($file)) { + continue; + } + $success = $this->doBackup($file, $project_root); + if (!$success) { + return FALSE; + } + } + $archive->extract($this->getTempDirectory(), [self::DELETION_MANIFEST]); + foreach ($this->getDeletions() as $deletion) { + $success = $this->doBackup($deletion, $project_root); + if (!$success) { + return FALSE; + } + } + return TRUE; + } + + /** + * Remove the files directory path from files from the archive. + * + * @param string $file + * The file path. + * + * @return bool + * TRUE if path was removed, else FALSE. + */ + protected function stripFileDirectoryPath(&$file) { + if (substr($file, 0, 6) === self::ARCHIVE_DIRECTORY) { + $file = substr($file, 6); + return TRUE; + } + return FALSE; + } + + /** + * Execute file backup. + * + * @param string $file + * The file to backup. + * @param string $project_root + * The project root directory. + * + * @return bool + * Return TRUE if backup succeeds, FALSE otherwise. + */ + protected function doBackup($file, $project_root) { + $directory = $this->backup . dirname($file); + if (!file_exists($directory) && !$this->fileSystem->mkdir($directory, NULL, TRUE)) { + return FALSE; + } + $project_real_path = $this->getProjectRealPath($file, $project_root); + if (file_exists($project_real_path) && !is_dir($project_real_path)) { + try { + $this->fileSystem->copy($project_real_path, $this->backup . $file, FileSystemInterface::EXISTS_REPLACE); + } + catch (FileException $exception) { + return FALSE; + } + } + return TRUE; + } + + /** + * Rollback after a failed update. + * + * @param string $project_root + * The project root directory. + */ + protected function rollback($project_root) { + if (!$this->backup) { + return; + } + foreach ($this->getFilesList($this->backup) as $file) { + $file_real_path = $this->getFileRealPath($file); + $file_path = substr($file_real_path, strlen($this->backup)); + try { + $this->fileSystem->copy($file_real_path, $this->getProjectRealPath($file_path, $project_root), FileSystemInterface::EXISTS_REPLACE); + } + catch (FileException $exception) { + $this->logger->error('@file was not rolled back successfully.', ['@file' => $file_real_path]); + } + } + } + + /** + * Provide a recursive list of files, excluding directories. + * + * @param string $directory + * The directory to recurse for files. + * + * @return \RecursiveIteratorIterator|\SplFileInfo[] + * The iterator of SplFileInfos. + */ + protected function getFilesList($directory) { + $filter = function ($file, $file_name, $iterator) { + /** @var \SplFileInfo $file */ + /** @var string $file_name */ + /** @var \RecursiveDirectoryIterator $iterator */ + if ($iterator->hasChildren() && !in_array($file->getFilename(), ['.git'], TRUE)) { + return TRUE; + } + return $file->isFile() && !in_array($file->getFilename(), [self::DELETION_MANIFEST], TRUE); + }; + + $innerIterator = new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS); + return new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator($innerIterator, $filter)); + } + + /** + * Build a project quasi-patch download URL. + * + * @param string $project_name + * The project name. + * @param string $file_name + * The file name. + * + * @return string + * The URL endpoint with for an extension. + */ + protected function buildUrl($project_name, $file_name) { + $uri = $this->configFactory->get('automatic_updates.settings')->get('download_uri'); + return Url::fromUri($uri . "/$project_name/" . $file_name)->toString(); + } + + /** + * Get the quasi-patch file name. + * + * @param string $project_name + * The project name. + * @param string $from_version + * The current project version. + * @param string $to_version + * The desired next project version. + * + * @return string + * The quasi-patch file name. + */ + protected function getQuasiPatchFileName($project_name, $from_version, $to_version) { + return "$project_name-$from_version-to-$to_version.zip"; + } + + /** + * Get file real path. + * + * @param \SplFileInfo $file + * The file to retrieve the real path. + * + * @return string + * The file real path. + */ + protected function getFileRealPath(\SplFileInfo $file) { + $real_path = $file->getRealPath(); + if (!$real_path) { + throw new FileException(sprintf('Could not get real path for "%s"', $file->getFilename())); + } + return $real_path; + } + + /** + * Get the real path of a file. + * + * @param string $file_path + * The file path. + * @param string $project_root + * The project root directory. + * + * @return string + * The real path of a file. + */ + protected function getProjectRealPath($file_path, $project_root) { + if (substr($file_path, 0, 6) === 'vendor/') { + return $this->vendorPath . substr($file_path, 7); + } + return rtrim($project_root, '/\\') . DIRECTORY_SEPARATOR . $file_path; + } + + /** + * Provides the temporary extraction directory. + * + * @return string + * The temporary directory. + */ + protected function getTempDirectory() { + if (!$this->tempDirectory) { + $this->tempDirectory = $this->fileSystem->createFilename('automatic_updates-update', 'temporary://'); + $this->fileSystem->prepareDirectory($this->tempDirectory); + $this->tempDirectory = $this->fileSystem->realpath($this->tempDirectory) . DIRECTORY_SEPARATOR; + } + return $this->tempDirectory; + } + + /** + * Get an iterator of files to delete. + * + * @return \ArrayIterator + * Iterator of files to delete. + */ + protected function getDeletions() { + $deletions = []; + if (!file_exists($this->getTempDirectory() . self::DELETION_MANIFEST)) { + return new \ArrayIterator($deletions); + } + $handle = fopen($this->getTempDirectory() . self::DELETION_MANIFEST, 'r'); + if ($handle) { + while (($deletion = fgets($handle)) !== FALSE) { + if ($result = trim($deletion)) { + $deletions[] = $result; + } + } + fclose($handle); + } + return new \ArrayIterator($deletions); + } + +} diff --git a/src/Services/UpdateInterface.php b/src/Services/UpdateInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..ee685a457878ab07cbbc90ed367be493ce593d2c --- /dev/null +++ b/src/Services/UpdateInterface.php @@ -0,0 +1,27 @@ +<?php + +namespace Drupal\automatic_updates\Services; + +/** + * Interface UpdateInterface. + */ +interface UpdateInterface { + + /** + * Update a project to the next release. + * + * @param string $project_name + * The project name. + * @param string $project_type + * The project type. + * @param string $from_version + * The current project version. + * @param string $to_version + * The desired next project version. + * + * @return bool + * TRUE if project was successfully updated, FALSE otherwise. + */ + public function update($project_name, $project_type, $from_version, $to_version); + +} diff --git a/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php b/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php new file mode 100644 index 0000000000000000000000000000000000000000..2d645eccf01635d3ecfe36f8182dbbffa6049014 --- /dev/null +++ b/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php @@ -0,0 +1,50 @@ +<?php + +namespace Drupal\test_automatic_updates\Controller; + +use Drupal\automatic_updates\Services\UpdateInterface; +use Drupal\Core\Controller\ControllerBase; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Returns responses for Test Automatic Updates routes. + */ +class InPlaceUpdateController extends ControllerBase { + + /** + * Updater service. + * + * @var \Drupal\automatic_updates\Services\UpdateInterface + */ + protected $updater; + + /** + * InPlaceUpdateController constructor. + * + * @param \Drupal\automatic_updates\Services\UpdateInterface $updater + * The updater service. + */ + public function __construct(UpdateInterface $updater) { + $this->updater = $updater; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('automatic_updates.update') + ); + } + + /** + * Builds the response. + */ + public function update($project, $type, $from, $to) { + $updated = $this->updater->update($project, $type, $from, $to); + return [ + '#markup' => $updated ? $this->t('Update successful') : $this->t('Update Failed'), + ]; + } + +} diff --git a/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php b/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..d8222b85a9c11f1a8f433885baa49edfb9c95884 --- /dev/null +++ b/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php @@ -0,0 +1,23 @@ +<?php + +namespace Drupal\test_automatic_updates\EventSubscriber; + +use Drupal\Core\Routing\RouteSubscriberBase; +use Symfony\Component\Routing\RouteCollection; + +/** + * Disable theme CSRF route subscriber. + */ +class DisableThemeCsrfRouteSubscriber extends RouteSubscriberBase { + + /** + * {@inheritdoc} + */ + protected function alterRoutes(RouteCollection $collection) { + // Disable CSRF so we can easily enable themes in tests. + if ($route = $collection->get('system.theme_set_default')) { + $route->setRequirements(['_permission' => 'administer themes']); + } + } + +} diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.info.yml b/tests/modules/test_automatic_updates/test_automatic_updates.info.yml index 506856cbe7688780451cf4d8b7993d39c0633c1c..1373e2ceb932dc5ccdf10e5f0cb3761c348cdf2f 100644 --- a/tests/modules/test_automatic_updates/test_automatic_updates.info.yml +++ b/tests/modules/test_automatic_updates/test_automatic_updates.info.yml @@ -3,3 +3,5 @@ type: module description: 'Tests for Automatic Updates' package: Testing core: 8.x +dependencies: + - automatic_updates:automatic_updates diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml index 7c9ce3413268e95199dd72aef1517776cb2dc40b..4d113f38e2e2ddf68ec41d3213877642247b753e 100644 --- a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml +++ b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml @@ -19,3 +19,12 @@ test_automatic_updates.hashes_endpoint: _title: 'SHA512SUMS' requirements: _access: 'TRUE' +test_automatic_updates.inplace-update: + path: '/automatic_updates/in-place-update/{project}/{type}/{from}/{to}' + defaults: + _title: 'Update' + _controller: '\Drupal\test_automatic_updates\Controller\InPlaceUpdateController::update' + requirements: + _access: 'TRUE' + options: + no_cache: 'TRUE' diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.services.yml b/tests/modules/test_automatic_updates/test_automatic_updates.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..da4b2779014a29aa173876e5289b2fc0b4b08df3 --- /dev/null +++ b/tests/modules/test_automatic_updates/test_automatic_updates.services.yml @@ -0,0 +1,5 @@ +services: + test_automatic_updates.route_subscriber: + class: Drupal\test_automatic_updates\EventSubscriber\DisableThemeCsrfRouteSubscriber + tags: + - { name: event_subscriber } diff --git a/tests/src/Build/InPlaceUpdateTest.php b/tests/src/Build/InPlaceUpdateTest.php new file mode 100644 index 0000000000000000000000000000000000000000..20aea7dbade381a1c5fc6ec5c70100b72b640dd2 --- /dev/null +++ b/tests/src/Build/InPlaceUpdateTest.php @@ -0,0 +1,288 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Build; + +use Drupal\automatic_updates\Services\InPlaceUpdate; +use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem; +use Drupal\Component\Utility\Html; +use Drupal\Tests\automatic_updates\Build\QuickStart\QuickStartTestBase; +use GuzzleHttp\Client; +use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem; +use Symfony\Component\Finder\Finder; + +/** + * @coversDefaultClass \Drupal\automatic_updates\Services\InPlaceUpdate + * + * @group Build + * @group Update + * + * @requires externalCommand composer + * @requires externalCommand curl + * @requires externalCommand git + * @requires externalCommand tar + */ +class InPlaceUpdateTest extends QuickStartTestBase { + + /** + * The files which are candidates for deletion during an upgrade. + * + * @var string[] + */ + protected $deletions; + + /** + * The directory where the deletion manifest is extracted. + * + * @var string + */ + protected $deletionsDestination; + + /** + * {@inheritdoc} + */ + protected function tearDown() { + parent::tearDown(); + $fs = new SymfonyFilesystem(); + $fs->remove($this->deletionsDestination); + } + + /** + * @covers ::update + * @dataProvider coreVersionsProvider + */ + public function testCoreUpdate($from_version, $to_version) { + $this->copyCodebase(); + // We have to fetch the tags for this shallow repo. It might not be a + // shallow clone, therefore we use executeCommand instead of assertCommand. + $this->executeCommand('git fetch --unshallow --tags'); + $this->executeCommand("git checkout $from_version -f"); + $this->assertCommandSuccessful(); + $fs = new SymfonyFilesystem(); + $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000); + $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction'); + $this->assertErrorOutputContains('Generating autoload files'); + $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer require ocramius/package-versions:^1.4 webflo/drupal-finder:^1.1 composer/semver:^1.0 --no-interaction'); + $this->assertErrorOutputContains('Generating autoload files'); + $this->installQuickStart('minimal'); + + // Currently, this test has to use extension_discovery_scan_tests so we can + // enable test modules. + $fs->chmod($this->getWorkspaceDirectory() . '/sites/default/settings.php', 0640, 0000); + file_put_contents($this->getWorkspaceDirectory() . '/sites/default/settings.php', '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL, FILE_APPEND); + + // Log in so that we can install modules. + $this->formLogin($this->adminUsername, $this->adminPassword); + $this->moduleEnable('automatic_updates'); + $this->moduleEnable('test_automatic_updates'); + + // Confirm we are running correct Drupal version. + $finder = new Finder(); + $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php'); + $finder->contains("/const VERSION = '$from_version'/"); + $this->assertTrue($finder->hasResults()); + + // Assert files slated for deletion still exist. + foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) { + $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); + } + + // Assert that the site is functional before updating. + $this->visit(); + $this->assertDrupalVisit(); + + // Update the site. + $this->visit("/automatic_updates/in-place-update/drupal/core/$from_version/$to_version"); + $this->assertDrupalVisit(); + + // Assert that the update worked. + $assert = $this->getMink()->assertSession(); + $assert->pageTextContains('Update successful'); + $finder = new Finder(); + $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php'); + $finder->contains("/const VERSION = '$to_version'/"); + $this->assertTrue($finder->hasResults()); + $this->visit('/admin/reports/status'); + $assert->pageTextContains("Drupal Version $to_version"); + + // Assert files slated for deletion are now gone. + foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) { + $this->assertFileNotExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); + } + } + + /** + * @covers ::update + * @dataProvider contribProjectsProvider + */ + public function testContribUpdate($project, $project_type, $from_version, $to_version) { + $this->copyCodebase(); + $fs = new SymfonyFilesystem(); + $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000); + $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction'); + $this->assertErrorOutputContains('Generating autoload files'); + $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer require ocramius/package-versions:^1.4 webflo/drupal-finder:^1.1 composer/semver:^1.0 --no-interaction'); + $this->assertErrorOutputContains('Generating autoload files'); + $this->installQuickStart('standard'); + + // Download the project. + $fs->mkdir($this->getWorkspaceDirectory() . "/{$project_type}s/contrib/$project"); + $this->executeCommand("curl -fsSL https://ftp.drupal.org/files/projects/$project-$from_version.tar.gz | tar xvz -C {$project_type}s/contrib/$project --strip 1"); + $this->assertCommandSuccessful(); + $finder = new Finder(); + $finder->files()->in($this->getWorkspaceDirectory())->path("{$project_type}s/contrib/$project/$project.info.yml"); + $finder->contains("/version: '$from_version'/"); + $this->assertTrue($finder->hasResults()); + + // Assert files slated for deletion still exist. + foreach ($this->getDeletions($project, $from_version, $to_version) as $deletion) { + $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); + } + + // Currently, this test has to use extension_discovery_scan_tests so we can + // enable test modules. + $fs->chmod($this->getWorkspaceDirectory() . '/sites/default/settings.php', 0640, 0000); + file_put_contents($this->getWorkspaceDirectory() . '/sites/default/settings.php', '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL, FILE_APPEND); + + // Log in so that we can install projects. + $this->formLogin($this->adminUsername, $this->adminPassword); + $this->moduleEnable('automatic_updates'); + $this->moduleEnable('test_automatic_updates'); + if (is_callable([$this, "{$project_type}Enable"])) { + call_user_func([$this, "{$project_type}Enable"], $project); + } + + // Assert that the site is functional before updating. + $this->visit(); + $this->assertDrupalVisit(); + + // Update the contrib project. + $this->visit("/automatic_updates/in-place-update/$project/$project_type/$from_version/$to_version"); + $this->assertDrupalVisit(); + + // Assert that the update worked. + $assert = $this->getMink()->assertSession(); + $assert->pageTextContains('Update successful'); + $finder = new Finder(); + $finder->files()->in($this->getWorkspaceDirectory())->path("{$project_type}s/contrib/$project/$project.info.yml"); + $finder->contains("/version: '$to_version'/"); + $this->assertTrue($finder->hasResults()); + $this->assertDrupalVisit(); + + // Assert files slated for deletion are now gone. + foreach ($this->getDeletions($project, $from_version, $to_version) as $deletion) { + $this->assertFileNotExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion); + } + } + + /** + * Core versions data provider. + */ + public function coreVersionsProvider() { + $datum[] = [ + 'from' => '8.7.0', + 'to' => '8.7.1', + ]; + $datum[] = [ + 'from' => '8.7.1', + 'to' => '8.7.2', + ]; + $datum[] = [ + 'from' => '8.7.2', + 'to' => '8.7.3', + ]; + $datum[] = [ + 'from' => '8.7.3', + 'to' => '8.7.4', + ]; + $datum[] = [ + 'from' => '8.7.4', + 'to' => '8.7.5', + ]; + $datum[] = [ + 'from' => '8.7.5', + 'to' => '8.7.6', + ]; + $datum[] = [ + 'from' => '8.7.6', + 'to' => '8.7.7', + ]; + return $datum; + } + + /** + * Contrib project data provider. + */ + public function contribProjectsProvider() { + $datum[] = [ + 'project' => 'bootstrap', + 'type' => 'theme', + 'from' => '8.x-3.19', + 'to' => '8.x-3.20', + ]; + $datum[] = [ + 'project' => 'token', + 'type' => 'module', + 'from' => '8.x-1.4', + 'to' => '8.x-1.5', + ]; + return $datum; + } + + /** + * Helper method to retrieve files slated for deletion. + */ + protected function getDeletions($project, $from_version, $to_version) { + if (isset($this->deletions)) { + return $this->deletions; + } + $this->deletions = []; + $http_client = new Client(); + $filesystem = new SymfonyFilesystem(); + $this->deletionsDestination = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . "$project-" . rand(10000, 99999) . microtime(TRUE); + $filesystem->mkdir($this->deletionsDestination); + $file_name = "$project-$from_version-to-$to_version.zip"; + $zip_file = $this->deletionsDestination . DIRECTORY_SEPARATOR . $file_name; + $http_client->get("https://www.drupal.org/in-place-updates/$project/$file_name", ['sink' => $zip_file]); + $zip = new \ZipArchive(); + $zip->open($zip_file); + $zip->extractTo($this->deletionsDestination, [InPlaceUpdate::DELETION_MANIFEST]); + $handle = fopen($this->deletionsDestination . DIRECTORY_SEPARATOR . InPlaceUpdate::DELETION_MANIFEST, 'r'); + if ($handle) { + while (($deletion = fgets($handle)) !== FALSE) { + if ($result = trim($deletion)) { + $this->deletions[] = $result; + } + } + fclose($handle); + } + return $this->deletions; + } + + /** + * Helper method that uses Drupal's module page to enable a module. + */ + protected function moduleEnable($module_name) { + $this->visit('/admin/modules'); + $field = Html::getClass("edit-modules $module_name enable"); + // No need to enable a module if it is already enabled. + if ($this->getMink()->getSession()->getPage()->findField($field)->isChecked()) { + return; + } + $assert = $this->getMink()->assertSession(); + $assert->fieldExists($field)->check(); + $session = $this->getMink()->getSession(); + $session->getPage()->findButton('Install')->submit(); + $assert->fieldExists($field)->isChecked(); + } + + /** + * Helper method that uses Drupal's theme page to enable a theme. + */ + protected function themeEnable($theme_name) { + $this->moduleEnable('test_automatic_updates'); + $this->visit("/admin/appearance/default?theme=$theme_name"); + $assert = $this->getMink()->assertSession(); + $assert->pageTextNotContains('theme was not found'); + } + +} diff --git a/tests/src/Build/QuickStart/QuickStartTestBase.php b/tests/src/Build/QuickStart/QuickStartTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..6ca9930638a9c7504b42415da095a54ef95ec9e3 --- /dev/null +++ b/tests/src/Build/QuickStart/QuickStartTestBase.php @@ -0,0 +1,68 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Build\QuickStart; + +use Drupal\BuildTests\Framework\BuildTestBase; +use Symfony\Component\Process\PhpExecutableFinder; + +/** + * Helper methods for using the quickstart feature of Drupal. + * + * @TODO: remove after https://www.drupal.org/project/drupal/issues/3082230. + */ +abstract class QuickStartTestBase extends BuildTestBase { + + /** + * User name of the admin account generated during install. + * + * @var string + */ + protected $adminUsername; + + /** + * Password of the admin account generated during install. + * + * @var string + */ + protected $adminPassword; + + /** + * Install a Drupal site using the quick start feature. + * + * @param string $profile + * Drupal profile to install. + * @param string $working_dir + * (optional) A working directory relative to the workspace, within which to + * execute the command. Defaults to the workspace directory. + */ + public function installQuickStart($profile, $working_dir = NULL) { + $finder = new PhpExecutableFinder(); + $process = $this->executeCommand($finder->find() . ' ./core/scripts/drupal install ' . $profile, $working_dir); + $this->assertCommandSuccessful(); + $this->assertCommandOutputContains('Username:'); + preg_match('/Username: (.+)\vPassword: (.+)/', $process->getOutput(), $matches); + $this->assertNotEmpty($this->adminUsername = $matches[1]); + $this->assertNotEmpty($this->adminPassword = $matches[2]); + } + + /** + * Helper that uses Drupal's user/login form to log in. + * + * @param string $username + * Username. + * @param string $password + * Password. + * @param string $working_dir + * (optional) A working directory within which to login. Defaults to the + * workspace directory. + */ + public function formLogin($username, $password, $working_dir = NULL) { + $mink = $this->visit('/user/login', $working_dir); + $this->assertEquals(200, $mink->getSession()->getStatusCode()); + $assert = $mink->assertSession(); + $assert->fieldExists('edit-name')->setValue($username); + $assert->fieldExists('edit-pass')->setValue($password); + $mink->getSession()->getPage()->findButton('Log in')->submit(); + } + +}