From 9b33bc5191b41927f43ac6884877e2dab15aede9 Mon Sep 17 00:00:00 2001 From: bnjmnm <bnjmnm@2369194.no-reply.drupal.org> Date: Wed, 5 Oct 2022 14:25:41 +0000 Subject: [PATCH] Issue #3284945 by bnjmnm, srishtiiee, tim.plunkett, rkoller, fjgarlin, tedbow, chrisfromredfin, phenaproxima: Install endpoints that leverage Package Manager + core APIs --- composer.json | 2 +- config/schema/project_browser.schema.yml | 3 + project_browser.routing.yml | 3 + project_browser.services.yml | 3 + src/ComposerInstaller/Installer.php | 22 + src/Controller/BrowserController.php | 10 +- src/Controller/InstallReadinessController.php | 10 + src/Controller/InstallerController.php | 556 +++++++++++++++++ src/Form/SettingsForm.php | 150 ++--- .../ProjectBrowserSource/MockDrupalDotOrg.php | 41 ++ src/Routing/ProjectBrowserRoutes.php | 165 +++++ .../project_browser_test.info.yml | 2 + .../project_browser_test.services.yml | 9 + .../src/Datetime/TestTime.php | 54 ++ .../src/DrupalOrgClientMiddleware.php | 54 ++ .../InstallReadinessControllerTest.php | 5 - .../Functional/InstallerControllerTest.php | 582 ++++++++++++++++++ ...jectBrowserInstallerFunctionalTestBase.php | 46 +- .../ProjectBrowserUiTest.php | 2 +- 19 files changed, 1626 insertions(+), 93 deletions(-) create mode 100644 src/Controller/InstallerController.php create mode 100644 src/Routing/ProjectBrowserRoutes.php create mode 100644 tests/modules/project_browser_test/project_browser_test.services.yml create mode 100644 tests/modules/project_browser_test/src/Datetime/TestTime.php create mode 100644 tests/modules/project_browser_test/src/DrupalOrgClientMiddleware.php create mode 100644 tests/src/Functional/InstallerControllerTest.php diff --git a/composer.json b/composer.json index ec7c26870..b1f0df0ee 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "ext-simplexml": "*" }, "require-dev": { - "drupal/automatic_updates": "2.x-dev@dev" + "drupal/automatic_updates": "^2.3" }, "config": { "optimize-autoloader": true, diff --git a/config/schema/project_browser.schema.yml b/config/schema/project_browser.schema.yml index c0dc952be..33d8ebdbb 100644 --- a/config/schema/project_browser.schema.yml +++ b/config/schema/project_browser.schema.yml @@ -8,3 +8,6 @@ project_browser.admin_settings: sequence: type: string label: 'Source' + allow_ui_install: + type: boolean + label: 'Allow installing packages from within the UI' diff --git a/project_browser.routing.yml b/project_browser.routing.yml index ab563bfe3..2cf9c7328 100644 --- a/project_browser.routing.yml +++ b/project_browser.routing.yml @@ -41,3 +41,6 @@ project_browser.install.readiness: _title: 'UI Install Readiness' requirements: _permission: 'administer modules' + +route_callbacks: + - '\Drupal\project_browser\Routing\ProjectBrowserRoutes::routes' diff --git a/project_browser.services.yml b/project_browser.services.yml index 2a17638fc..bf7f2de50 100644 --- a/project_browser.services.yml +++ b/project_browser.services.yml @@ -34,3 +34,6 @@ services: - { name: cache.bin } factory: cache_factory:get arguments: [project_browser] + project_browser.tempstore.shared: + class: Drupal\Core\TempStore\SharedTempStoreFactory + arguments: ['@keyvalue.expirable', '@lock', '@request_stack', '@current_user', 600] diff --git a/src/ComposerInstaller/Installer.php b/src/ComposerInstaller/Installer.php index 6ca712fb2..a4ede2619 100644 --- a/src/ComposerInstaller/Installer.php +++ b/src/ComposerInstaller/Installer.php @@ -42,4 +42,26 @@ final class Installer extends Stage { } } + /** + * Checks if the stage tempstore lock was created by Project Browser. + * + * This is one of several checks performed to determine if it is acceptable + * to destroy the current stage. Project Browser's unlock functionality uses + * the "force" option so a stage can be destroyed even if it was created by + * a different user or during a different session. However, a stage could have + * been created by another module, such as Automatic Updates. In those cases + * Project Browser should not have the ability to destroy the stage. + * + * This method confirms the staging lock was created by + * Drupal\project_browser\ComposerInstaller\Installer, and will only permit + * destroying the stage if true. + * + * @return bool + * True if the stage tempstore lock was created by Project Browser. + */ + public function lockCameFromProjectBrowserInstaller(): bool { + $lock_data = $this->tempStore->get(static::TEMPSTORE_LOCK_KEY); + return !empty($lock_data[1]) && $lock_data[1] === self::class; + } + } diff --git a/src/Controller/BrowserController.php b/src/Controller/BrowserController.php index 83dca1562..d667d117d 100644 --- a/src/Controller/BrowserController.php +++ b/src/Controller/BrowserController.php @@ -10,7 +10,6 @@ use Drupal\project_browser\EnabledSourceHandler; use Drupal\project_browser\Plugin\ProjectBrowserSourceBase; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; -use Drupal\Core\Messenger\MessengerInterface; /** * Defines a controller to provide the Project Browser UI. @@ -66,16 +65,13 @@ class BrowserController extends ControllerBase { * The request stack. * @param \Drupal\project_browser\EnabledSourceHandler $enabled_source * The enabled source. - * @param \Drupal\Core\Messenger\MessengerInterface $messenger - * The messenger. */ - public function __construct(ModuleHandlerInterface $module_handler, ModuleExtensionList $module_list, RequestStack $request_stack, EnabledSourceHandler $enabled_source, MessengerInterface $messenger) { + public function __construct(ModuleHandlerInterface $module_handler, ModuleExtensionList $module_list, RequestStack $request_stack, EnabledSourceHandler $enabled_source) { $this->moduleHandler = $module_handler; $this->moduleList = $module_list; $this->requestStack = $request_stack; $this->enabledSource = $enabled_source; $this->cacheBin = $this->cache('project_browser'); - $this->messenger = $messenger; } /** @@ -87,7 +83,6 @@ class BrowserController extends ControllerBase { $container->get('extension.list.module'), $container->get('request_stack'), $container->get('project_browser.enabled_source'), - $container->get('messenger'), ); } @@ -113,7 +108,7 @@ class BrowserController extends ControllerBase { $current_sources = $this->enabledSource->getCurrentSources(); if (!empty($current_sources['drupalorg_mockapi']) && !$module_name) { - $this->messenger + $this->messenger() ->addStatus($this->t('Project Browser is currently a prototype, and the projects listed may not be up to date with Drupal.org. For the most updated list of projects, please visit <a href=":url">:url</a>', [':url' => 'https://www.drupal.org/project/project_module'])) ->addStatus($this->t('Your feedback and input are welcome at <a href=":url">:url</a>', [':url' => 'https://www.drupal.org/project/issues/project_browser'])); } @@ -142,6 +137,7 @@ class BrowserController extends ControllerBase { 'development_options' => $current_source->getDevelopmentOptions(), 'default_plugin_id' => $current_source->getPluginId(), 'current_sources_keys' => $current_sources_keys, + 'ui_install' => (bool) $this->config('project_browser.admin_settings')->get('allow_ui_install'), ], ], ], diff --git a/src/Controller/InstallReadinessController.php b/src/Controller/InstallReadinessController.php index 4535fa422..6d8a14924 100644 --- a/src/Controller/InstallReadinessController.php +++ b/src/Controller/InstallReadinessController.php @@ -61,6 +61,16 @@ class InstallReadinessController extends ControllerBase { * The response. */ public function checkReadiness() { + // If the installer is not available, the fact that it is claimed implies + // the validators pass, so we can set 'pm_validation' to FALSE, knowing that + // an install attempt will include the same validation present in a status + // check thus providing the same protection. + if (!$this->installer->isAvailable()) { + return new JsonResponse([ + 'pm_validation' => FALSE, + 'stage_available' => $this->installer->isAvailable(), + ]); + } $status_check_event = $this->eventDispatcher->dispatch(new StatusCheckEvent($this->installer)); $text = ''; diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php new file mode 100644 index 000000000..74f4567f8 --- /dev/null +++ b/src/Controller/InstallerController.php @@ -0,0 +1,556 @@ +<?php + +namespace Drupal\project_browser\Controller; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\Core\TempStore\SharedTempStoreFactory; +use Drupal\Core\Url; +use Drupal\package_manager\Exception\StageException; +use Drupal\package_manager\PathLocator; +use Drupal\project_browser\ComposerInstaller\Installer; +use Drupal\project_browser\EnabledSourceHandler; +use Psr\Log\LoggerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; + +/** + * Defines a controller to install projects via UI. + */ +class InstallerController extends ControllerBase { + + /** + * No require or install in progress for a given module. + * + * @var int + */ + protected const STATUS_IDLE = 0; + + /** + * A staging install in progress for a given module. + * + * @var int + */ + protected const STATUS_REQUIRING_PROJECT = 1; + + /** + * A core install in progress for a given project. + * + * @var int + */ + protected const STATUS_INSTALLING_PROJECT = 2; + + /** + * The endpoint successfully returned the expected data. + * + * @var int + */ + protected const STAGE_STATUS_OK = 0; + + /** + * The installer. + * + * @var \Drupal\project_browser\ComposerInstaller\Installer + */ + private $installer; + + /** + * The Project Browser tempstore object. + * + * @var \Drupal\Core\TempStore\SharedTempStore + */ + protected $projectBrowserTempStore; + + /** + * The shared tempstore object. + * + * @var \Drupal\Core\TempStore\SharedTempStore + */ + protected $sharedTempStore; + + /** + * The module installer. + * + * @var \Drupal\Core\Extension\ModuleInstallerInterface + */ + protected $moduleInstaller; + + /** + * The EnabledSourceHandler. + * + * @var \Drupal\project_browser\EnabledSourceHandler + */ + protected $enabledSourceHandler; + + /** + * The time service. + * + * @var \Drupal\Component\Datetime\TimeInterface + */ + protected $time; + + /** + * A logger instance. + * + * @var \Psr\Log\LoggerInterface + */ + protected $logger; + + /** + * Constructs an InstallerController object. + * + * @param \Drupal\project_browser\ComposerInstaller\Installer $installer + * The installer. + * @param \Drupal\package_manager\PathLocator $path_locator + * The path locator service. + * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_temp_store_factory + * The shared tempstore factory. + * @param \Drupal\Core\Extension\ModuleInstallerInterface $module_installer + * The module installer. + * @param \Drupal\project_browser\EnabledSourceHandler $enabled_source + * The enabled source. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \Psr\Log\LoggerInterface $logger + * A logger instance. + */ + public function __construct(Installer $installer, PathLocator $path_locator, SharedTempStoreFactory $shared_temp_store_factory, ModuleInstallerInterface $module_installer, EnabledSourceHandler $enabled_source, TimeInterface $time, LoggerInterface $logger) { + $this->installer = $installer; + $this->pathLocator = $path_locator; + $this->projectBrowserTempStore = $shared_temp_store_factory->get('project_browser'); + $this->sharedTempStore = $shared_temp_store_factory; + $this->moduleInstaller = $module_installer; + $this->enabledSourceHandler = $enabled_source; + $this->time = $time; + $this->logger = $logger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('project_browser.installer'), + $container->get('package_manager.path_locator'), + $container->get('project_browser.tempstore.shared'), + $container->get('module_installer'), + $container->get('project_browser.enabled_source'), + $container->get('datetime.time'), + $container->get('logger.channel.project_browser'), + ); + } + + /** + * Checks if UI install is enabled on the site. + */ + public function access() :AccessResult { + $ui_install = $this->config('project_browser.admin_settings')->get('allow_ui_install'); + return AccessResult::allowedIf((bool) $ui_install); + } + + /** + * Nulls the installing and core installing states. + */ + private function resetProgress(): void { + $this->projectBrowserTempStore->delete('requiring'); + $this->projectBrowserTempStore->delete('installing'); + } + + /** + * Resets progress and destroys the stage. + */ + private function cancelRequire(): void { + $this->resetProgress(); + // Checking the for the presence of a lock in the package manager stage is + // necessary as this method can be called during create(), which includes both + // the PreCreate and PostCreate events. If an exception is caught during + // PreCreate, there's no stage to destroy and an exception would be raised. + // So, we check for the presence of a stage before calling destroy(). + if (!$this->installer->isAvailable()) { + $this->installer->destroy(); + } + } + + /** + * Returns the status of the project in the temp store. + * + * @param string $project_id + * The project machine name. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Information about the project's require/install status. + * + * If the project is being required, the response will include which require + * phase is currently occurring. + * + * When a project is required via the UI, the UI fetches this endpoint + * regularly so it can monitor the progress of the process and report which + * stage is taking place. + */ + public function inProgress(string $project_id): JsonResponse { + $requiring = $this->projectBrowserTempStore->get('requiring'); + $core_installing = $this->projectBrowserTempStore->get('installing'); + $status = self::STATUS_IDLE; + if (isset($requiring['project_id']) && $requiring['project_id'] === $project_id) { + $status = self::STATUS_REQUIRING_PROJECT; + } + if ($core_installing === $project_id) { + $status = self::STATUS_INSTALLING_PROJECT; + } + $return = ['status' => $status]; + if ($status !== self::STATUS_IDLE) { + $return['phase'] = $requiring['phase']; + } + return new JsonResponse($return); + } + + /** + * Provides a JSON response for a given error. + * + * @param \Exception $e + * The error that occurred. + * @param string $phase + * The phase the error occurred in. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Provides an error message to be displayed by the Project Browser UI. + */ + private function errorResponse(\Exception $e, string $phase = ''): JsonResponse { + $exception_type_short = (new \ReflectionClass($e))->getShortName(); + $exception_message = $e->getMessage(); + $response_body = ['message' => "$exception_type_short: $exception_message"]; + $this->logger->warning('@exception_type: @exception_message. @trace ', [ + '@exception_type' => get_class($e), + '@exception_message' => $exception_message, + '@trace' => $e->getTraceAsString(), + ]); + + if (!empty($phase)) { + $response_body['phase'] = $phase; + } + return new JsonResponse($response_body, 500); + } + + /** + * Provides a JSON response for a successful request. + * + * @param string $phase + * The phase the request was made in. + * @param string|null $stage_id + * The stage id of the installer within the request. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Provides information about the completed operation. + */ + private function successResponse(string $phase, ?string $stage_id = NULL): JsonResponse { + $response_body = [ + 'phase' => $phase, + 'status' => self::STAGE_STATUS_OK, + ]; + if (!empty($stage_id)) { + $response_body['stage_id'] = $stage_id; + } + return new JsonResponse($response_body); + } + + /** + * Provides a JSON response for require requests while the stage is locked. + * + * @param string $message + * The message content of the response. + * @param string $unlock_url + * An unlock url provided in instances where unlocking is safe. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Provides a message regarding the status of the staging lock. + * + * If the stage is not in a phase where it is unsafe to unlock, a CSRF + * protected unlock URL is also provided. + */ + private function lockedResponse(string $message, string $unlock_url = ''): JsonResponse { + return new JsonResponse([ + 'message' => $message, + 'unlock_url' => $unlock_url, + ], 418); + } + + /** + * Updates the 'requiring' state in the temp store. + * + * @param string $project_id + * The module being required. + * @param string $phase + * The require phase in progress. + * @param string $stage_id + * The stage id. + */ + private function setRequiringState(string $project_id, string $phase, string $stage_id = ''): void { + $this->projectBrowserTempStore->set('requiring', [ + 'project_id' => $project_id, + 'phase' => $phase, + 'stage_id' => $stage_id, + ]); + } + + /** + * Unlocks and destroys the stage. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse + * Redirects to the main project browser page. + * + * @todo add return type when php 7.4 support ends. + */ + public function unlock() { + try { + // It's possible the unlock url was provided before applying began, but + // accessed after. This final check ensures a destroy is not attempted + // during apply. + if ($this->installer->isApplying()) { + throw new StageException('A stage can not be unlocked while applying'); + } + + // Adding the TRUE parameter to destroy is dangerous, but we provide it + // here for a few reasons. + // - This endpoint is only available if it's confirmed the stage lock was + // created by Drupal\project_browser\ComposerInstaller\Installer. + // - This endpoint is not available if the stage is applying. + // - In the event of a flawed install, we want it to be possible for users + // to unlock the stage via the GUI, even if they're not the user that + // initiated the install. + // - The unlock link is accompanied by information regarding when the + // stage was locked, and warns the user when the time is recent enough + // that they risk aborting a legitimate install. + $this->installer->destroy(TRUE); + } + catch (\Exception $e) { + return $this->errorResponse($e); + } + $this->projectBrowserTempStore->delete('requiring'); + $this->messenger()->addStatus($this->t('Install staging area unlocked.')); + return $this->redirect('project_browser.browse'); + } + + /** + * Gets the given URL with all placeholders replaced. + * + * @param \Drupal\Core\Url $url + * A URL which generates CSRF token placeholders. + * + * @return string + * The URL string, with all placeholders replaced. + */ + private static function getUrlWithReplacedCsrfTokenPlaceholder(Url $url): string { + $generated_url = $url->toString(TRUE); + $url_with_csrf_token_placeholder = [ + '#plain_text' => $generated_url->getGeneratedUrl(), + ]; + $generated_url->applyTo($url_with_csrf_token_placeholder); + return (string) \Drupal::service('renderer')->renderPlain($url_with_csrf_token_placeholder); + } + + /** + * Begins requiring by creating a stage. + * + * @param string $composer_namespace + * The project composer namespace. + * @param string $project_id + * The project id. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Status message. + */ + public function begin(string $composer_namespace, string $project_id): JsonResponse { + // @todo Expand to support other plugins in https://drupal.org/i/3312354. + $source = $this->enabledSourceHandler->getCurrentSources()['drupalorg_mockapi'] ?? NULL; + if ($source === NULL) { + return new JsonResponse(['message' => "Cannot download $project_id from any available source"], 500); + } + if (!$source->isProjectSafe($project_id)) { + return new JsonResponse(['message' => "$project_id is not safe to add because its security coverage has been revoked"], 500); + } + $stage_available = $this->installer->isAvailable(); + if (!$stage_available) { + $requiring_metadata = $this->projectBrowserTempStore->getMetadata('requiring'); + if (!$this->installer->lockCameFromProjectBrowserInstaller()) { + return $this->lockedResponse($this->t('The installation stage is locked by a process outside of Project Browser'), ''); + } + if (empty($requiring_metadata)) { + $unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder( + Url::fromRoute('project_browser.install.unlock') + ); + $message = t('An install staging area claimed by Project Browser exists but has expired. You may unlock the stage and try the install again.'); + return $this->lockedResponse($message, $unlock_url); + } + $time_since_updated = $this->time->getRequestTime() - $requiring_metadata->getUpdated(); + $hours = (int) gmdate("H", $time_since_updated); + $minutes = (int) gmdate("i", $time_since_updated); + $minutes = $time_since_updated > 60 ? $minutes : 'less than 1'; + if ($this->installer->isApplying()) { + $message = empty(floor($hours)) ? + $this->t('The install staging area was locked @minutes minutes ago. It should not be unlocked as the changes from staging are being applied to the site.', ['@minutes' => $minutes]) : + $this->t('The install staging area was locked @hours hours, @minutes minutes ago. It should not be unlocked as the changes from staging are being applied to the site.', ['@hours' => $hours, '@minutes' => $minutes]); + return $this->lockedResponse($message, ''); + + } + elseif ($hours === 0 && ($minutes < 7 || $minutes === 'less than 1')) { + $message = $this->t('The install staging area was locked @minutes minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.', ['@minutes' => $minutes]); + } + else { + $message = empty($hours) ? + $this->t('The install staging area was locked @minutes minutes ago.', ['@minutes' => $minutes]) : + $this->t('The install staging area was locked @hours hours, @minutes minutes ago.', ['@hours' => $hours, '@minutes' => $minutes]); + } + + $unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder( + Url::fromRoute('project_browser.install.unlock') + ); + return $this->lockedResponse($message, $unlock_url); + } + + try { + $stage_id = $this->installer->create(); + $this->setRequiringState($project_id, 'starting install', $stage_id); + } + catch (\Exception $e) { + $this->cancelRequire(); + return $this->errorResponse($e, 'create'); + } + + return $this->successResponse('create', $stage_id); + } + + /** + * Performs require operations on the stage. + * + * @param string $composer_namespace + * The project composer namespace. + * @param string $project_id + * The project id. + * @param string $stage_id + * ID of stage created in the begin() method. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Status message. + */ + public function require(string $composer_namespace, string $project_id, string $stage_id): JsonResponse { + $requiring = $this->projectBrowserTempStore->get('requiring'); + if (empty($requiring['project_id']) || $requiring['project_id'] !== $project_id) { + return new JsonResponse([ + 'message' => sprintf('Error: a request to install %s was ignored as an install for a different module is in progress.', $project_id), + ], 500); + } + $this->setRequiringState($project_id, 'requiring module', $stage_id); + try { + $this->installer->claim($stage_id)->require(["$composer_namespace/$project_id"]); + } + catch (\Exception $e) { + $this->cancelRequire(); + return $this->errorResponse($e, 'require'); + } + return $this->successResponse('require', $stage_id); + } + + /** + * Performs apply operations on the stage. + * + * @param string $composer_namespace + * The project composer namespace. + * @param string $project_id + * The project id. + * @param string $stage_id + * ID of stage created in the begin() method. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Status message. + */ + public function apply(string $composer_namespace, string $project_id, string $stage_id): JsonResponse { + $this->setRequiringState($project_id, 'applying', $stage_id); + try { + $this->installer->claim($stage_id)->apply(); + } + catch (\Exception $e) { + $this->cancelRequire(); + return $this->errorResponse($e, 'apply'); + } + return $this->successResponse('apply', $stage_id); + } + + /** + * Performs post apply operations on the stage. + * + * @param string $composer_namespace + * The project composer namespace. + * @param string $project_id + * The project id. + * @param string $stage_id + * ID of stage created in the begin() method. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Status message. + */ + public function postApply(string $composer_namespace, string $project_id, string $stage_id): JsonResponse { + $this->setRequiringState($project_id, 'post apply', $stage_id); + try { + $this->installer->claim($stage_id)->postApply(); + } + catch (\Exception $e) { + return $this->errorResponse($e, 'post apply'); + } + return $this->successResponse('post apply', $stage_id); + } + + /** + * Performs destroy operations on the stage. + * + * @param string $composer_namespace + * The project composer namespace. + * @param string $project_id + * The project id. + * @param string $stage_id + * ID of stage created in the begin() method. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Status message. + */ + public function destroy(string $composer_namespace, string $project_id, string $stage_id): JsonResponse { + $this->setRequiringState($project_id, 'completing', $stage_id); + try { + $this->installer->claim($stage_id)->destroy(); + } + catch (\Exception $e) { + return $this->errorResponse($e, 'destroy'); + } + $this->projectBrowserTempStore->delete('requiring'); + return new JsonResponse([ + 'phase' => 'destroy', + 'status' => self::STAGE_STATUS_OK, + 'stage_id' => $stage_id, + 'message' => $this->t('Project @project was downloaded successfully', ['@project' => $project_id]), + ]); + } + + /** + * Installs an already downloaded module. + * + * @param string $project_id + * The project machine name. + * + * @return \Symfony\Component\HttpFoundation\JsonResponse + * Status message. + */ + public function activateModule(string $project_id): JsonResponse { + $this->projectBrowserTempStore->set('installing', $project_id); + try { + $this->moduleInstaller->install([$project_id]); + } + catch (\Exception $e) { + $this->resetProgress(); + return $this->errorResponse($e, 'project install'); + } + $this->projectBrowserTempStore->delete('installing'); + return $this->successResponse('project install'); + } + +} diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php index 752006455..1fd5d0e9a 100644 --- a/src/Form/SettingsForm.php +++ b/src/Form/SettingsForm.php @@ -88,24 +88,25 @@ class SettingsForm extends ConfigFormBase { */ public function buildForm(array $form, FormStateInterface $form_state) { $config = $this->config('project_browser.admin_settings'); - $source_plugins = $this->manager->getDefinitions(); - if (count($source_plugins) <= 1) { - return [ - '#type' => 'markup', - '#markup' => $this->t('At least two source plugins are required to configure this feature.'), - ]; - } + $form['allow_ui_install'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Allow installing via UI (experimental)'), + '#default_value' => $config->get('allow_ui_install'), + '#description' => $this->t('When enabled (and Package Manager from the Automatic Updates module is installed), modules can be downloaded and enabled via the Project Browser UI.'), + ]; + + $source_plugins = $this->manager->getDefinitions(); $enabled_sources = $config->get('enabled_sources'); // Sort the source plugins by the order they're stored in config. $sorted_arr = array_merge(array_flip($enabled_sources), $source_plugins); - $source_plugins = array_merge($sorted_arr, $source_plugins); $weight_delta = round(count($source_plugins) / 2); $table = [ '#type' => 'table', '#header' => $this->getTableHeader(), + '#empty' => $this->t('At least two source plugins are required to configure this feature.'), '#attributes' => [ 'id' => 'project_browser', ], @@ -114,82 +115,84 @@ class SettingsForm extends ConfigFormBase { 'enabled' => $this->t('Enabled'), 'disabled' => $this->t('Disabled'), ]; - foreach ($options as $status => $title) { - $table['#tabledrag'][] = [ - 'action' => 'match', - 'relationship' => 'sibling', - 'group' => 'source-status-select', - 'subgroup' => 'source-status-' . $status, - 'hidden' => FALSE, - ]; - $table['#tabledrag'][] = [ - 'action' => 'order', - 'relationship' => 'sibling', - 'group' => 'source-weight', - 'subgroup' => 'source-weight-' . $status, - ]; - $table['status-' . $status] = [ - '#attributes' => [ - 'class' => ['status-title', 'status-title-' . $status], - 'no_striping' => TRUE, - ], - ]; - $table['status-' . $status]['title'] = [ - '#plain_text' => $title, - '#wrapper_attributes' => [ - 'colspan' => 3, - ], - ]; - - // Plugin rows. - foreach ($source_plugins as $plugin_name => $plugin_definition) { - // Only include plugins in their respective section. - if (($status === 'enabled') !== in_array($plugin_name, $enabled_sources, TRUE)) { - continue; - } - - $label = (string) $plugin_definition['label']; - $plugin_key_exists = array_search($plugin_name, $enabled_sources); - $table[$plugin_name] = [ - '#attributes' => [ - 'class' => [ - 'draggable', - ], - ], + if (count($source_plugins) > 1) { + $form['#attached']['library'][] = 'project_browser/tabledrag'; + foreach ($options as $status => $title) { + $table['#tabledrag'][] = [ + 'action' => 'match', + 'relationship' => 'sibling', + 'group' => 'source-status-select', + 'subgroup' => 'source-status-' . $status, + 'hidden' => FALSE, ]; - $table[$plugin_name]['source'] = [ - '#plain_text' => $label, - '#wrapper_attributes' => [ - 'id' => 'source--' . $plugin_name, - ], + $table['#tabledrag'][] = [ + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => 'source-weight', + 'subgroup' => 'source-weight-' . $status, ]; - - $table[$plugin_name]['status'] = [ - '#type' => 'select', - '#default_value' => $status, - '#required' => TRUE, - '#title' => $this->t('Status for @source source', ['@source' => $label]), - '#title_display' => 'invisible', - '#options' => $options, + $table['status-' . $status] = [ '#attributes' => [ - 'class' => ['source-status-select', 'source-status-' . $status], + 'class' => ['status-title', 'status-title-' . $status], + 'no_striping' => TRUE, ], ]; - $table[$plugin_name]['weight'] = [ - '#type' => 'weight', - '#default_value' => ($plugin_key_exists === FALSE) ? 0 : $plugin_key_exists, - '#delta' => $weight_delta, - '#title' => $this->t('Weight for @source source', ['@source' => $label]), - '#title_display' => 'invisible', - '#attributes' => [ - 'class' => ['source-weight', 'source-weight-' . $status], + $table['status-' . $status]['title'] = [ + '#plain_text' => $title, + '#wrapper_attributes' => [ + 'colspan' => 3, ], ]; + + // Plugin rows. + foreach ($source_plugins as $plugin_name => $plugin_definition) { + // Only include plugins in their respective section. + if (($status === 'enabled') !== in_array($plugin_name, $enabled_sources, TRUE)) { + continue; + } + + $label = (string) $plugin_definition['label']; + $plugin_key_exists = array_search($plugin_name, $enabled_sources); + $table[$plugin_name] = [ + '#attributes' => [ + 'class' => [ + 'draggable', + ], + ], + ]; + $table[$plugin_name]['source'] = [ + '#plain_text' => $label, + '#wrapper_attributes' => [ + 'id' => 'source--' . $plugin_name, + ], + ]; + + $table[$plugin_name]['status'] = [ + '#type' => 'select', + '#default_value' => $status, + '#required' => TRUE, + '#title' => $this->t('Status for @source source', ['@source' => $label]), + '#title_display' => 'invisible', + '#options' => $options, + '#attributes' => [ + 'class' => ['source-status-select', 'source-status-' . $status], + ], + ]; + $table[$plugin_name]['weight'] = [ + '#type' => 'weight', + '#default_value' => ($plugin_key_exists === FALSE) ? 0 : $plugin_key_exists, + '#delta' => $weight_delta, + '#title' => $this->t('Weight for @source source', ['@source' => $label]), + '#title_display' => 'invisible', + '#attributes' => [ + 'class' => ['source-weight', 'source-weight-' . $status], + ], + ]; + } } } $form['enabled_sources'] = $table; - $form['#attached']['library'][] = 'project_browser/tabledrag'; return parent::buildForm($form, $form_state); } @@ -211,6 +214,7 @@ class SettingsForm extends ConfigFormBase { $enabled_plugins = array_filter($all_plugins, fn($source) => $source['status'] === 'enabled'); $this->config('project_browser.admin_settings') ->set('enabled_sources', array_keys($enabled_plugins)) + ->set('allow_ui_install', $form_state->getValue('allow_ui_install')) ->save(); $this->cacheBin->deleteAll(); parent::submitForm($form, $form_state); diff --git a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php index 6bb8f6be3..ff83315f4 100644 --- a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php +++ b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php @@ -12,6 +12,8 @@ use Drupal\project_browser\Plugin\ProjectBrowserSourceBase; use Drupal\project_browser\ProjectBrowser\Project; use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\TransferStats; use Psr\Log\LoggerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -542,4 +544,43 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase implements ContainerFact return $body; } + /** + * Checks if a project's security coverage has been revoked. + * + * @param string $project_id + * The project id. + * + * @return bool + * False if the project's security coverage is revoked, otherwise true. + */ + public function isProjectSafe(string $project_id): bool { + try { + $response = $this->httpClient->request('GET', "https://www.drupal.org/api-d7/node.json", [ + 'on_stats' => static function (TransferStats $stats) use (&$url) { + $url = $stats->getEffectiveUri(); + }, + 'query' => ['field_project_machine_name' => $project_id], + ]); + } + catch (RequestException $re) { + // Try a second time because sometimes d.o times out the request. + $response = $this->httpClient->request('GET', "https://www.drupal.org/api-d7/node.json", [ + 'on_stats' => static function (TransferStats $stats) use (&$url) { + $url = $stats->getEffectiveUri(); + }, + 'query' => ['field_project_machine_name' => $project_id], + ]); + } + if ($response->getStatusCode() !== 200) { + throw new \RuntimeException("Request to $url failed, returned {$response->getStatusCode()} with reason: {$response->getReasonPhrase()}"); + } + + $project_info = Json::decode($response->getBody()->getContents()); + if (isset($project_info['list'][0]['field_security_advisory_coverage']) && count($project_info['list']) === 1) { + return $project_info['list'][0]['field_security_advisory_coverage'] !== 'revoked'; + } + + return FALSE; + } + } diff --git a/src/Routing/ProjectBrowserRoutes.php b/src/Routing/ProjectBrowserRoutes.php new file mode 100644 index 000000000..e864ab957 --- /dev/null +++ b/src/Routing/ProjectBrowserRoutes.php @@ -0,0 +1,165 @@ +<?php + +namespace Drupal\project_browser\Routing; + +use Drupal\project_browser\Controller\InstallerController; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Routing\Route; + +/** + * Provides routes for Project Browser. + * + * @internal + * Routing callbacks are internal. + */ +class ProjectBrowserRoutes implements ContainerInjectionInterface { + + /** + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + private ModuleHandlerInterface $moduleHandler; + + /** + * Constructs a new ProjectBrowserRoutes object. + * + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + */ + public function __construct(ModuleHandlerInterface $module_handler) { + $this->moduleHandler = $module_handler; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('module_handler'), + ); + } + + /** + * Returns an array of route objects. + * + * @return \Symfony\Component\Routing\Route[] + * An array of route objects. + */ + public function routes(): array { + if (!$this->moduleHandler->moduleExists('package_manager')) { + return []; + } + $routes = []; + $machine_name_regex = '[a-zA-Z_]+'; + $stage_id_regex = '[a-zA-Z0-9_-]+'; + $routes['project_browser.stage.begin'] = new Route( + '/admin/modules/project_browser/install-begin/{composer_namespace}/{project_id}', + [ + '_controller' => InstallerController::class . '::begin', + '_title' => 'Create phase', + ], + [ + '_permission' => 'administer modules', + 'composer_namespace' => $machine_name_regex, + 'project_id' => $machine_name_regex, + '_custom_access' => InstallerController::class . '::access', + ], + ); + $routes['project_browser.stage.require'] = new Route( + '/admin/modules/project_browser/install-require/{composer_namespace}/{project_id}/{stage_id}', + [ + '_controller' => InstallerController::class . '::require', + '_title' => 'Require phase', + ], + [ + '_permission' => 'administer modules', + 'composer_namespace' => $machine_name_regex, + 'project_id' => $machine_name_regex, + 'stage_id' => $stage_id_regex, + '_custom_access' => InstallerController::class . '::access', + ], + ); + $routes['project_browser.stage.apply'] = new Route( + '/admin/modules/project_browser/install-apply/{composer_namespace}/{project_id}/{stage_id}', + [ + '_controller' => InstallerController::class . '::apply', + '_title' => 'Apply phase', + ], + [ + '_permission' => 'administer modules', + 'composer_namespace' => $machine_name_regex, + 'project_id' => $machine_name_regex, + 'stage_id' => $stage_id_regex, + '_custom_access' => InstallerController::class . '::access', + ], + ); + $routes['project_browser.stage.post_apply'] = new Route( + '/admin/modules/project_browser/install-post_apply/{composer_namespace}/{project_id}/{stage_id}', + [ + '_controller' => InstallerController::class . '::postApply', + '_title' => 'Post apply phase', + ], + [ + '_permission' => 'administer modules', + 'composer_namespace' => $machine_name_regex, + 'project_id' => $machine_name_regex, + 'stage_id' => $stage_id_regex, + '_custom_access' => InstallerController::class . '::access', + ], + ); + $routes['project_browser.stage.destroy'] = new Route( + '/admin/modules/project_browser/install-destroy/{composer_namespace}/{project_id}/{stage_id}', + [ + '_controller' => InstallerController::class . '::destroy', + '_title' => 'Destroy phase', + ], + [ + '_permission' => 'administer modules', + 'composer_namespace' => $machine_name_regex, + 'project_id' => $machine_name_regex, + 'stage_id' => $stage_id_regex, + '_custom_access' => InstallerController::class . '::access', + ], + ); + $routes['project_browser.activate.module'] = new Route( + '/admin/modules/project_browser/activate-module/{project_id}', + [ + '_controller' => InstallerController::class . '::activateModule', + '_title' => 'Install module in core', + ], + [ + '_permission' => 'administer modules', + 'project_id' => $machine_name_regex, + '_custom_access' => InstallerController::class . '::access', + ], + ); + $routes['project_browser.module.install_in_progress'] = new Route( + '/admin/modules/project_browser/install_in_progress/{project_id}', + [ + '_controller' => InstallerController::class . '::inProgress', + '_title' => 'Install in progress', + ], + [ + '_permission' => 'administer modules', + 'project_id' => $machine_name_regex, + '_custom_access' => InstallerController::class . '::access', + ], + ); + $routes['project_browser.install.unlock'] = new Route( + '/admin/modules/project_browser/install/unlock', + [ + '_controller' => InstallerController::class . '::unlock', + '_title' => 'Unlock', + ], + [ + '_permission' => 'administer modules', + '_csrf_token' => 'TRUE', + '_custom_access' => InstallerController::class . '::access', + ], + ); + + return $routes; + } + +} diff --git a/tests/modules/project_browser_test/project_browser_test.info.yml b/tests/modules/project_browser_test/project_browser_test.info.yml index 8775148a0..ccb21531e 100644 --- a/tests/modules/project_browser_test/project_browser_test.info.yml +++ b/tests/modules/project_browser_test/project_browser_test.info.yml @@ -2,3 +2,5 @@ name: Project Browser test type: module description: 'Support module for testing Project Browser.' core_version_requirement: ^9 || ^10 +dependencies: + - project_browser:project_browser diff --git a/tests/modules/project_browser_test/project_browser_test.services.yml b/tests/modules/project_browser_test/project_browser_test.services.yml new file mode 100644 index 000000000..e157cf9c8 --- /dev/null +++ b/tests/modules/project_browser_test/project_browser_test.services.yml @@ -0,0 +1,9 @@ +services: + project_browser_test.time: + class: Drupal\project_browser_test\Datetime\TestTime + decorates: datetime.time + arguments: ['@project_browser_test.time.inner','@request_stack'] + project_browser_test.drupalorg_client_middleware: + class: Drupal\project_browser_test\DrupalOrgClientMiddleware + tags: + - { name: http_client_middleware } diff --git a/tests/modules/project_browser_test/src/Datetime/TestTime.php b/tests/modules/project_browser_test/src/Datetime/TestTime.php new file mode 100644 index 000000000..320f18fe1 --- /dev/null +++ b/tests/modules/project_browser_test/src/Datetime/TestTime.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\project_browser_test\Datetime; + +use Drupal\Component\Datetime\Time; +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Test service for altering the request time. + */ +class TestTime extends Time { + + /** + * The time service. + * + * @var \Drupal\Component\Datetime\Time + */ + protected $decoratorTime; + + /** + * Constructs an Updater object. + * + * @param \Drupal\Component\Datetime\Time $time + * The time service. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The RequestStack object. + */ + public function __construct(Time $time, RequestStack $request_stack) { + $this->decoratorTime = $time; + parent::__construct($request_stack); + } + + /** + * {@inheritdoc} + */ + public function getRequestTime(): int { + if ($faked_date = \Drupal::state()->get('project_browser_test.fake_date_time')) { + return \DateTime::createFromFormat('U', $faked_date)->getTimestamp(); + } + return $this->decoratorTime->getRequestTime(); + } + + /** + * Sets a fake time from an offset that will be used in the test. + * + * @param string $offset + * A date/time offset string as used by \DateTime::modify. + */ + public static function setFakeTimeByOffset(string $offset): void { + $fake_time = (new \DateTime())->modify($offset)->format('U'); + \Drupal::state()->set('project_browser_test.fake_date_time', $fake_time); + } + +} diff --git a/tests/modules/project_browser_test/src/DrupalOrgClientMiddleware.php b/tests/modules/project_browser_test/src/DrupalOrgClientMiddleware.php new file mode 100644 index 000000000..50dcb5a21 --- /dev/null +++ b/tests/modules/project_browser_test/src/DrupalOrgClientMiddleware.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\project_browser_test; + +use Drupal\Component\Serialization\Json; +use GuzzleHttp\Promise\FulfilledPromise; +use GuzzleHttp\Psr7\Response; +use Psr\Http\Message\RequestInterface; + +/** + * Middleware to intercept Drupal.org API requests during tests. + */ +class DrupalOrgClientMiddleware { + + /** + * Invoked method that returns a promise. + */ + public function __invoke() { + return function ($handler) { + return function (RequestInterface $request, array $options) use ($handler) { + $json_response = ''; + if ($request->getUri()->getPath() === '/api-d7/node.json') { + $uri_query = $request->getUri()->getQuery(); + $covered = [ + 'field_project_machine_name=awesome_module', + 'field_project_machine_name=core', + 'field_project_machine_name=metatag', + + ]; + if (in_array($uri_query, $covered)) { + $json_response = new Response(200, [], Json::encode([ + 'list' => [ + ['field_security_advisory_coverage' => 'covered'], + ], + ])); + } + if ($uri_query === 'field_project_machine_name=security_revoked_module') { + $json_response = new Response(200, [], Json::encode([ + 'list' => [ + ['field_security_advisory_coverage' => 'revoked'], + ], + ])); + } + if (!empty($json_response)) { + return new FulfilledPromise($json_response); + } + } + + return $handler($request, $options); + }; + }; + } + +} diff --git a/tests/src/Functional/InstallReadinessControllerTest.php b/tests/src/Functional/InstallReadinessControllerTest.php index c84d8d8cf..78c44f964 100644 --- a/tests/src/Functional/InstallReadinessControllerTest.php +++ b/tests/src/Functional/InstallReadinessControllerTest.php @@ -13,11 +13,6 @@ use Drupal\package_manager\ValidationResult; */ class InstallReadinessControllerTest extends ProjectBrowserInstallerFunctionalTestBase { - /** - * {@inheritdoc} - */ - protected $defaultTheme = 'stark'; - /** * {@inheritdoc} */ diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php new file mode 100644 index 000000000..411748cfe --- /dev/null +++ b/tests/src/Functional/InstallerControllerTest.php @@ -0,0 +1,582 @@ +<?php + +namespace Drupal\Tests\project_browser\Functional; + +use Drupal\Component\Serialization\Json; +use Drupal\package_manager\ValidationResult; +use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber; +use Drupal\package_manager\Event\PreCreateEvent; +use Drupal\package_manager\Event\PreRequireEvent; +use Drupal\package_manager\Event\PreApplyEvent; +use Drupal\package_manager\Event\PreDestroyEvent; +use Drupal\package_manager\Event\PostApplyEvent; +use Drupal\package_manager\Event\PostCreateEvent; +use Drupal\package_manager\Event\PostRequireEvent; +use Drupal\package_manager\Event\PostDestroyEvent; +use Drupal\project_browser_test\Datetime\TestTime; + +/** + * Tests the installer controller. + * + * @coversDefaultClass \Drupal\project_browser\Controller\InstallerController + * + * @group project_browser + */ +class InstallerControllerTest extends ProjectBrowserInstallerFunctionalTestBase { + + /** + * The shared tempstore object. + * + * @var \Drupal\Core\TempStore\SharedTempStore + */ + protected $sharedTempStore; + + /** + * A stage id. + * + * @var string + */ + protected $stageId; + + /** + * The installer. + * + * @var \Drupal\project_browser\ComposerInstaller\Installer + */ + private $installer; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'project_browser', + 'project_browser_test', + 'system', + 'user', + ]; + + protected function setUp(): void { + parent::setUp(); + $connection = $this->container->get('database'); + $query = $connection->insert('project_browser_projects')->fields([ + 'nid', + 'title', + 'author', + 'created', + 'changed', + 'project_usage_total', + 'maintenance_status', + 'development_status', + 'status', + 'field_security_advisory_coverage', + 'flag_project_star_user_count', + 'field_project_type', + 'project_data', + 'field_project_machine_name', + ]); + $query->values([ + 'nid' => 111, + 'title' => 'An Awesome Module', + 'author' => 'Detective Crashmore', + 'created' => 1383917647, + 'changed' => 1663534145, + 'project_usage_total' => 455, + 'maintenance_status' => 13028, + 'development_status' => 9988, + 'status' => 1, + 'field_security_advisory_coverage' => 'covered', + 'flag_project_star_user_count' => 0, + 'field_project_type' => 'full', + 'project_data' => serialize([]), + 'field_project_machine_name' => 'awesome_module', + ]); + $query->values([ + 'nid' => 222, + 'title' => 'Security Revoked Module', + 'author' => 'Jamie Taco', + 'created' => 1383917448, + 'changed' => 1663534145, + 'project_usage_total' => 455, + 'maintenance_status' => 13028, + 'development_status' => 9988, + 'status' => 1, + 'field_security_advisory_coverage' => 'covered', + 'flag_project_star_user_count' => 0, + 'field_project_type' => 'full', + 'project_data' => serialize([]), + 'field_project_machine_name' => 'awesome_module', + ]); + $query->execute(); + $this->sharedTempStore = $this->container->get('tempstore.shared'); + $this->installer = $this->container->get('project_browser.installer'); + $this->drupalLogin($this->drupalCreateUser(['administer modules'])); + $this->config('project_browser.admin_settings')->set('allow_ui_install', TRUE)->save(); + } + + /** + * Confirms install endpoint not available if UI installs are not enabled. + * + * @covers ::access + */ + public function testUiInstallUnavailableIfDisabled() { + $this->config('project_browser.admin_settings')->set('allow_ui_install', FALSE)->save(); + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(403); + $this->assertSession()->pageTextContains('Access denied'); + } + + /** + * Confirms prevention of requiring modules with revoked security status. + * + * @covers ::begin + */ + public function testInstallSecurityRevokedModule() { + $this->assertProjectBrowserTempStatus(NULL, NULL); + $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/security_revoked_module'); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"security_revoked_module is not safe to add because its security coverage has been revoked"}', $content); + } + + /** + * Confirms a require will stop if package already present. + * + * @covers::require + */ + public function testInstallAlreadyPresentPackage() { + $this->assertProjectBrowserTempStatus(NULL, NULL); + // Though core is not available as a choice in project browser, it works + // well for the purposes of this test as it's definitely already added + // via composer. + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/core'); + $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0]; + $content = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/core/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"InstallException: The following package is already installed:\ndrupal\/core\n","phase":"require"}', $content); + } + + /** + * Calls the endpoint that begins installation. + * + * @covers::begin + */ + private function doStart() { + $this->assertProjectBrowserTempStatus(NULL, NULL); + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0]; + $this->assertSession()->statusCodeEquals(200); + $expected_output = sprintf('{"phase":"create","status":0,"stage_id":"%s"}', $this->stageId); + $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); + $this->assertInstallInProgress('awesome_module', 'starting install'); + } + + /** + * Calls the endpoint that continues to the require phase of installation. + * + * @covers::require + */ + private function doRequire() { + $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId"); + $expected_output = sprintf('{"phase":"require","status":0,"stage_id":"%s"}', $this->stageId); + $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); + $this->assertInstallInProgress('awesome_module', 'requiring module'); + } + + /** + * Calls the endpoint that continues to the apply phase of installation. + * + * @covers::apply + */ + private function doApply() { + $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId"); + $expected_output = sprintf('{"phase":"apply","status":0,"stage_id":"%s"}', $this->stageId); + $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); + $this->assertInstallInProgress('awesome_module', 'applying'); + } + + /** + * Calls the endpoint that continues to the post apply phase of installation. + * + * @covers::postApply + */ + private function doPostApply() { + $this->drupalGet("/admin/modules/project_browser/install-post_apply/drupal/awesome_module/$this->stageId"); + $expected_output = sprintf('{"phase":"post apply","status":0,"stage_id":"%s"}', $this->stageId); + $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); + $this->assertInstallInProgress('awesome_module', 'post apply'); + } + + /** + * Calls the endpoint that continues to the destroy phase of installation. + * + * @covers::destroy + */ + private function doDestroy() { + $this->drupalGet("/admin/modules/project_browser/install-destroy/drupal/awesome_module/$this->stageId"); + $expected_output = sprintf('{"phase":"destroy","status":0,"stage_id":"%s","message":"Project awesome_module was downloaded successfully"}', $this->stageId); + $this->assertSame($expected_output, $this->getSession()->getPage()->getContent()); + $this->assertInstallNotInProgress('awesome_module'); + } + + /** + * Calls every endpoint needed to do a UI install and confirms they work. + */ + public function testUiInstallerEndpoints() { + $this->doStart(); + $this->doRequire(); + $this->doApply(); + $this->doPostApply(); + $this->doDestroy(); + } + + /** + * Tests an error during a pre create event. + * + * @covers::create + */ + public function testPreCreateError() { + $message = t('This is a PreCreate error.'); + $result = ValidationResult::createError([$message]); + TestSubscriber::setTestResult([$result], PreCreateEvent::class); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"InstallException: This is a PreCreate error.\n","phase":"create"}', $contents); + } + + /** + * Tests an exception during a pre create event. + * + * @covers::create + */ + public function testPreCreateException() { + $error = new \Exception('PreCreate did not go well.'); + TestSubscriber::setException($error, PreCreateEvent::class); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"StageException: PreCreate did not go well.","phase":"create"}', $contents); + } + + /** + * Tests an exception during a post create event. + * + * @covers::create + */ + public function testPostCreateException() { + $error = new \Exception('PostCreate did not go well.'); + TestSubscriber::setException($error, PostCreateEvent::class); + $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"StageException: PostCreate did not go well.","phase":"create"}', $contents); + } + + /** + * Tests an error during a pre require event. + * + * @covers::require + */ + public function testPreRequireError() { + $message = t('This is a PreRequire error.'); + $result = ValidationResult::createError([$message]); + $this->doStart(); + TestSubscriber::setTestResult([$result], PreRequireEvent::class); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"InstallException: This is a PreRequire error.\n","phase":"require"}', $contents); + } + + /** + * Tests an exception during a pre require event. + * + * @covers::require + */ + public function testPreRequireException() { + $error = new \Exception('PreRequire did not go well.'); + TestSubscriber::setException($error, PreRequireEvent::class); + $this->doStart(); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"StageException: PreRequire did not go well.","phase":"require"}', $contents); + } + + /** + * Tests an exception during a post require event. + * + * @covers::require + */ + public function testPostRequireException() { + $error = new \Exception('PostRequire did not go well.'); + TestSubscriber::setException($error, PostRequireEvent::class); + $this->doStart(); + $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"StageException: PostRequire did not go well.","phase":"require"}', $contents); + } + + /** + * Tests an error during a pre apply event. + * + * @covers::apply + */ + public function testPreApplyError() { + $message = t('This is a PreApply error.'); + $result = ValidationResult::createError([$message]); + TestSubscriber::setTestResult([$result], PreApplyEvent::class); + $this->doStart(); + $this->doRequire(); + $contents = $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"InstallException: This is a PreApply error.\n","phase":"apply"}', $contents); + } + + /** + * Tests an exception during a pre apply event. + * + * @covers::apply + */ + public function testPreApplyException() { + $error = new \Exception('PreApply did not go well.'); + TestSubscriber::setException($error, PreApplyEvent::class); + $this->doStart(); + $this->doRequire(); + $contents = $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"StageException: PreApply did not go well.","phase":"apply"}', $contents); + } + + /** + * Tests an exception during a post apply event. + * + * @covers::apply + */ + public function testPostApplyException() { + $error = new \Exception('PostApply did not go well.'); + TestSubscriber::setException($error, PostApplyEvent::class); + $this->doStart(); + $this->doRequire(); + $this->doApply(); + $contents = $this->drupalGet("/admin/modules/project_browser/install-post_apply/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"StageException: PostApply did not go well.","phase":"post apply"}', $contents); + } + + /** + * Tests an error during a pre destroy event. + * + * @covers::destroy + */ + public function testPreDestroyError() { + $message = t('This is a PreDestroy error.'); + $result = ValidationResult::createError([$message]); + TestSubscriber::setTestResult([$result], PreDestroyEvent::class); + $this->doStart(); + $this->doRequire(); + $this->doApply(); + $this->doPostApply(); + $contents = $this->drupalGet("/admin/modules/project_browser/install-destroy/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"InstallException: This is a PreDestroy error.\n","phase":"destroy"}', $contents); + } + + /** + * Tests an exception during a pre destroy event. + * + * @covers::destroy + */ + public function testPreDestroyException() { + $error = new \Exception('PreDestroy did not go well.'); + TestSubscriber::setException($error, PreDestroyEvent::class); + $this->doStart(); + $this->doRequire(); + $this->doApply(); + $this->doPostApply(); + $contents = $this->drupalGet("/admin/modules/project_browser/install-destroy/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"StageException: PreDestroy did not go well.","phase":"destroy"}', $contents); + } + + /** + * Tests an exception during a post destroy event. + * + * @covers::destroy + */ + public function testPostDestroyException() { + $error = new \Exception('PostDestroy did not go well.'); + TestSubscriber::setException($error, PostDestroyEvent::class); + $this->doStart(); + $this->doRequire(); + $this->doApply(); + $this->doPostApply(); + $contents = $this->drupalGet("/admin/modules/project_browser/install-destroy/drupal/awesome_module/$this->stageId"); + $this->assertSession()->statusCodeEquals(500); + $this->assertSame('{"message":"StageException: PostDestroy did not go well.","phase":"destroy"}', $contents); + } + + /** + * Confirms the various versions of the "install in progress" messages. + * + * @covers::unlock + */ + public function testInstallUnlockMessage() { + $this->doStart(); + + // Check for mid install unlock offer message. + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(418); + $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked less than 1 minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); + $this->assertInstallInProgress('awesome_module', 'starting install'); + $this->assertFalse($this->installer->isAvailable()); + $this->assertFalse($this->installer->isApplying()); + TestTime::setFakeTimeByOffset("+800 seconds"); + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(418); + $this->assertFalse($this->installer->isAvailable()); + $this->assertFalse($this->installer->isApplying()); + $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); + $this->doRequire(); + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(418); + $this->assertFalse($this->installer->isAvailable()); + $this->assertFalse($this->installer->isApplying()); + $this->doApply(); + TestTime::setFakeTimeByOffset('+800 seconds'); + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(418); + $this->assertFalse($this->installer->isAvailable()); + $this->assertTrue($this->installer->isApplying()); + $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent()); + TestTime::setFakeTimeByOffset("+55 minutes"); + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(418); + $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 55 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent()); + // Unlocking the stage becomes possible after 1 hour regardless of source. + TestTime::setFakeTimeByOffset("+75 minutes"); + $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module'); + $this->assertSession()->statusCodeEquals(418); + $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 1 hours, 15 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent()); + } + + /** + * Confirms the break lock link is available and works. + * + * The break lock link is not available once the stage is applying. + * + * @covers::unlock + */ + public function testCanBreakLock() { + $this->doStart(); + // Try beginning another install while one is in progress, but not yet in + // the applying stage. + $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag'); + $this->assertSession()->statusCodeEquals(418); + $this->assertFalse($this->installer->isAvailable()); + $this->assertFalse($this->installer->isApplying()); + $json = Json::decode($content); + $this->assertSame('The install staging area was locked less than 1 minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.', $json['message']); + $path = explode('?', $json['unlock_url'])[0]; + $token = explode('=', $json['unlock_url'])[1]; + $unlock_content = $this->drupalGet(substr($path, 1), ['query' => ['token' => $token]]); + $this->assertSession()->statusCodeEquals(200); + $this->assertTrue($this->installer->isAvailable()); + $this->assertStringContainsString('Install staging area unlocked', $unlock_content); + $this->assertTrue($this->installer->isAvailable()); + $this->assertFalse($this->installer->isApplying()); + } + + /** + * Confirms stage can be unlocked despite a missing Project Browser lock. + * + * @covers::unlock + */ + public function testCanBreakStageWithMissingProjectBrowserLock() { + $this->doStart(); + $this->sharedTempStore->get('project_browser')->delete('requiring'); + $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag'); + $this->assertSession()->statusCodeEquals(418); + $this->assertFalse($this->installer->isAvailable()); + $this->assertFalse($this->installer->isApplying()); + $json = Json::decode($content); + $this->assertSame('An install staging area claimed by Project Browser exists but has expired. You may unlock the stage and try the install again.', $json['message']); + $path = explode('?', $json['unlock_url'])[0]; + $token = explode('=', $json['unlock_url'])[1]; + $unlock_content = $this->drupalGet(substr($path, 1), ['query' => ['token' => $token]]); + $this->assertSession()->statusCodeEquals(200); + $this->assertTrue($this->installer->isAvailable()); + $this->assertStringContainsString('Install staging area unlocked', $unlock_content); + $this->assertTrue($this->installer->isAvailable()); + $this->assertFalse($this->installer->isApplying()); + } + + /** + * Confirm a module and its dependencies can be installed via the endpoint. + * + * @covers::activateModule + */ + public function testCoreModuleActivate() { + $this->drupalGet('admin/modules'); + $views_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-enable'); + $views_ui_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-ui-enable'); + $this->assertFalse($views_checkbox->isChecked()); + $this->assertFalse($views_ui_checkbox->isChecked()); + + $content = $this->drupalGet('admin/modules/project_browser/activate-module/views_ui'); + $this->assertSame('{"phase":"project install","status":0}', $content); + $this->rebuildContainer(); + $this->drupalGet('admin/modules'); + $views_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-enable'); + $views_ui_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-ui-enable'); + $this->assertTrue($views_checkbox->isChecked()); + $this->assertTrue($views_ui_checkbox->isChecked()); + } + + /** + * Asserts that a module install is not in progress. + * + * @param string $module + * The module machine name. + */ + protected function assertInstallNotInProgress($module) { + $this->assertProjectBrowserTempStatus(NULL, NULL); + $this->drupalGet("/admin/modules/project_browser/install_in_progress/$module"); + $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); + $this->drupalGet('/admin/modules/project_browser/install_in_progress/metatag'); + $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); + } + + /** + * Confirms the project browser in progress input provides the expected value. + * + * @param string $module + * The module being enabled. + * @param string $phase + * The install phase. + */ + protected function assertInstallInProgress($module, $phase = NULL) { + $expect_install = ['project_id' => $module]; + if (!is_null($phase)) { + $expect_install['phase'] = $phase; + } + if (!empty($this->stageId)) { + $expect_install['stage_id'] = $this->stageId; + } + $this->assertProjectBrowserTempStatus($expect_install, NULL); + $this->drupalGet("/admin/modules/project_browser/install_in_progress/$module"); + $this->assertSame(sprintf('{"status":1,"phase":"%s"}', $phase), $this->getSession()->getPage()->getContent()); + $this->drupalGet('/admin/modules/project_browser/install_in_progress/metatag'); + $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent()); + } + + /** + * Confirms the tempstore install status are as expected. + * + * @param array $expected_requiring + * The expected value of the 'requiring' state. + * @param string $expected_installing + * The expected value of the 'core requiring' state. + */ + protected function assertProjectBrowserTempStatus($expected_requiring, $expected_installing) { + $project_browser_requiring = $this->sharedTempStore->get('project_browser')->get('requiring'); + $project_browser_installing = $this->sharedTempStore->get('project_browser')->get('installing'); + $this->assertSame($expected_requiring, $project_browser_requiring); + $this->assertSame($expected_installing, $project_browser_installing); + } + +} diff --git a/tests/src/Functional/ProjectBrowserInstallerFunctionalTestBase.php b/tests/src/Functional/ProjectBrowserInstallerFunctionalTestBase.php index c068f80bf..774ba2090 100644 --- a/tests/src/Functional/ProjectBrowserInstallerFunctionalTestBase.php +++ b/tests/src/Functional/ProjectBrowserInstallerFunctionalTestBase.php @@ -3,10 +3,16 @@ namespace Drupal\Tests\project_browser\Functional; use Drupal\Core\Site\Settings; +use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait; +use Drupal\package_manager_bypass\Beginner; use Drupal\Tests\BrowserTestBase; abstract class ProjectBrowserInstallerFunctionalTestBase extends BrowserTestBase { + use FixtureUtilityTrait; + + protected $defaultTheme = 'stark'; + /** * {@inheritdoc} */ @@ -23,13 +29,8 @@ abstract class ProjectBrowserInstallerFunctionalTestBase extends BrowserTestBase * @var string[] */ protected $disableValidators = [ - // Must be disabled as the DrupalCI site has non-writable files that - // the validator requires to be writable. - 'package_manager.validator.file_system', - // Must be disabled because symlinks are present in the Drupal instance - // provided by DrupalCI. + // Symlinks are part of the DrupalCI filesystem. 'package_manager.validator.symlink', - 'package_manager.validator.staged_database_updates', ]; /** @@ -38,6 +39,8 @@ abstract class ProjectBrowserInstallerFunctionalTestBase extends BrowserTestBase protected function setUp(): void { parent::setUp(); $this->disableValidators($this->disableValidators); + $pm_path = $this->container->get('extension.list.module')->getPath('package_manager'); + $this->useFixtureDirectoryAsActive($pm_path . '/tests/fixtures/fake_site'); } /** @@ -70,4 +73,35 @@ abstract class ProjectBrowserInstallerFunctionalTestBase extends BrowserTestBase $this->rebuildContainer(); } + /** + * Sets a fixture directory to use as the active directory. + * + * @param string $fixture_directory + * The fixture directory. + */ + protected function useFixtureDirectoryAsActive(string $fixture_directory): void { + // Create a temporary directory from our fixture directory that will be + // unique for each test run. This will enable changing files in the + // directory and not affect other tests. + $active_dir = $this->copyFixtureToTempDirectory($fixture_directory); + Beginner::setFixturePath($active_dir); + $this->container->get('package_manager.path_locator') + ->setPaths($active_dir, $active_dir . '/vendor', '', NULL); + } + + /** + * Copies a fixture directory to a temporary directory. + * + * @param string $fixture_directory + * The fixture directory. + * + * @return string + * The temporary directory. + */ + protected function copyFixtureToTempDirectory(string $fixture_directory): string { + $temp_directory = $this->root . DIRECTORY_SEPARATOR . $this->siteDirectory . DIRECTORY_SEPARATOR . $this->randomMachineName(20); + static::copyFixtureFilesTo($fixture_directory, $temp_directory); + return $temp_directory; + } + } diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php index 5d96d1a41..351869d2e 100644 --- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php +++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php @@ -804,7 +804,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase { $this->container->get('module_installer')->install(['project_browser_devel'], TRUE); $this->drupalGet('admin/modules/browse'); - $assert_session->waitForElementVisible('css', '#project-browser .project'); + $assert_session->waitForElementVisible('css', '.plugin-tabs button'); // Count tabs. $tab_count = $page->findAll('css', '.plugin-tabs button'); $this->assertCount(2, $tab_count); -- GitLab