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