diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml index d0d414912d3cc741aac57ce0a262243ba76bda7d..07e2a63068e3c97c250d9b414d7e74d833c57b92 100644 --- a/automatic_updates.services.yml +++ b/automatic_updates.services.yml @@ -18,6 +18,7 @@ services: - '@package_manager.committer' - '@package_manager.cleaner' - '@event_dispatcher' + - '@tempstore.shared' automatic_updates.update_refresh_subscriber: class: Drupal\automatic_updates\Event\UpdateRefreshSubscriber arguments: diff --git a/package_manager/src/Stage.php b/package_manager/src/Stage.php index d854e2a5d9f69b127c08d2b9f85fb32a09897f7e..e835eefd3a0e29467e132c7cb038f4a93f90e13b 100644 --- a/package_manager/src/Stage.php +++ b/package_manager/src/Stage.php @@ -2,6 +2,7 @@ namespace Drupal\package_manager; +use Drupal\Core\TempStore\SharedTempStoreFactory; use Drupal\package_manager\Event\PostApplyEvent; use Drupal\package_manager\Event\PostCreateEvent; use Drupal\package_manager\Event\PostDestroyEvent; @@ -70,6 +71,21 @@ class Stage { */ protected $eventDispatcher; + + /** + * The tempstore key under which to store the active status of this stage. + * + * @var string + */ + protected const TEMPSTORE_ACTIVE_KEY = 'active'; + + /** + * The shared temp store. + * + * @var \Drupal\Core\TempStore\SharedTempStore + */ + protected $tempStore; + /** * Constructs a new Stage object. * @@ -85,20 +101,55 @@ class Stage { * The cleaner service from Composer Stager. * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher * The event dispatcher service. + * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore + * The shared tempstore factory. */ - public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, CleanerInterface $cleaner, EventDispatcherInterface $event_dispatcher) { + public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, CleanerInterface $cleaner, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore) { $this->pathLocator = $path_locator; $this->beginner = $beginner; $this->stager = $stager; $this->committer = $committer; $this->cleaner = $cleaner; $this->eventDispatcher = $event_dispatcher; + $this->tempStore = $shared_tempstore->get('package_manager_stage'); + } + + /** + * Determines if the staging area can be created. + * + * @return bool + * TRUE if the staging area can be created, otherwise FALSE. + */ + final public function isAvailable(): bool { + return empty($this->tempStore->getMetadata(static::TEMPSTORE_ACTIVE_KEY)); + } + + /** + * Determines if the current user or session is the owner of the staging area. + * + * @return bool + * TRUE if the current session or user is the owner of the staging area, + * otherwise FALSE. + */ + final public function isOwnedByCurrentUser(): bool { + return !empty($this->tempStore->getIfOwner(static::TEMPSTORE_ACTIVE_KEY)); } /** * Copies the active code base into the staging area. */ public function create(): void { + if (!$this->isAvailable()) { + throw new StageException([], 'Cannot create a new stage because one already exists.'); + } + // Mark the stage as unavailable as early as possible, before dispatching + // the pre-create event. The idea is to prevent a race condition if the + // event subscribers take a while to finish, and two different users attempt + // to create a staging area at around the same time. If an error occurs + // while the event is being processed, the stage is marked as available. + // @see ::dispatch() + $this->tempStore->set(static::TEMPSTORE_ACTIVE_KEY, TRUE); + $active_dir = $this->pathLocator->getActiveDirectory(); $stage_dir = $this->pathLocator->getStageDirectory(); @@ -119,6 +170,8 @@ class Stage { * Defaults to FALSE. */ public function require(array $constraints, bool $dev = FALSE): void { + $this->checkOwnership(); + $command = array_merge(['require'], $constraints); $command[] = '--update-with-all-dependencies'; if ($dev) { @@ -134,6 +187,8 @@ class Stage { * Applies staged changes to the active directory. */ public function apply(): void { + $this->checkOwnership(); + $active_dir = $this->pathLocator->getActiveDirectory(); $stage_dir = $this->pathLocator->getStageDirectory(); @@ -146,13 +201,26 @@ class Stage { /** * Deletes the staging area. + * + * @param bool $force + * (optional) If TRUE, the staging area will be destroyed even if it is not + * owned by the current user or session. Defaults to FALSE. + * + * @todo Do not allow the stage to be destroyed while it's being applied to + * the active directory in https://www.drupal.org/i/3248909. */ - public function destroy(): void { + public function destroy(bool $force = FALSE): void { + if (!$force) { + $this->checkOwnership(); + } + $this->dispatch(new PreDestroyEvent($this)); $stage_dir = $this->pathLocator->getStageDirectory(); if (is_dir($stage_dir)) { $this->cleaner->clean($stage_dir); } + // We're all done, so mark the stage as available. + $this->tempStore->delete(static::TEMPSTORE_ACTIVE_KEY); $this->dispatch(new PostDestroyEvent($this)); } @@ -161,13 +229,37 @@ class Stage { * * @param \Drupal\package_manager\Event\StageEvent $event * The event object. + * + * @throws \Drupal\package_manager\StageException + * If the event collects any validation errors, or a subscriber throws a + * StageException directly. + * @throws \RuntimeException + * If any other sort of error occurs. */ protected function dispatch(StageEvent $event): void { - $this->eventDispatcher->dispatch($event); + try { + $this->eventDispatcher->dispatch($event); + + $errors = $event->getResults(SystemManager::REQUIREMENT_ERROR); + if ($errors) { + throw new StageException($errors); + } + } + catch (\Throwable $error) { + // If we are not going to be able to create the staging area, mark it as + // available. + // @see ::create() + if ($event instanceof PreCreateEvent) { + $this->tempStore->delete(static::TEMPSTORE_ACTIVE_KEY); + } - $errors = $event->getResults(SystemManager::REQUIREMENT_ERROR); - if ($errors) { - throw new StageException($errors); + // Wrap the exception to preserve the backtrace, and re-throw it. + if ($error instanceof StageException) { + throw new StageException($error->getResults(), $error->getMessage(), $error->getCode(), $error); + } + else { + throw new \RuntimeException($error->getMessage(), $error->getCode(), $error); + } } } @@ -193,4 +285,16 @@ class Stage { return ComposerUtility::createForDirectory($dir); } + /** + * Ensures that the current user or session owns the staging area. + * + * @throws \Drupal\package_manager\StageException + * If the current user or session does not own the staging area. + */ + protected function checkOwnership(): void { + if (!$this->isOwnedByCurrentUser()) { + throw new StageException([], 'Stage is not owned by the current user or session.'); + } + } + } diff --git a/package_manager/tests/src/Functional/ExcludedPathsTest.php b/package_manager/tests/src/Functional/ExcludedPathsTest.php index fd28b082abeb31ac76b886c643de488c91ffaf8e..9de0de61efab77f6331b120311e1bc280a379cc0 100644 --- a/package_manager/tests/src/Functional/ExcludedPathsTest.php +++ b/package_manager/tests/src/Functional/ExcludedPathsTest.php @@ -98,7 +98,8 @@ class ExcludedPathsTest extends BrowserTestBase { $this->container->get('package_manager.stager'), $this->container->get('package_manager.committer'), $this->container->get('package_manager.cleaner'), - $this->container->get('event_dispatcher') + $this->container->get('event_dispatcher'), + $this->container->get('tempstore.shared'), ); $stage->create(); diff --git a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php index 846426f14fce291559e7c957a34437600f058a47..626c4e38411bf69240cf18975466bbafc1061e8d 100644 --- a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php +++ b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php @@ -59,6 +59,7 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { $this->container->get('package_manager.committer'), $this->container->get('package_manager.cleaner'), $this->container->get('event_dispatcher'), + $this->container->get('tempstore.shared') ); } @@ -93,6 +94,22 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase { } } + /** + * Marks all pending post-update functions as completed. + * + * Since kernel tests don't normally install modules and register their + * updates, this method makes sure that we are testing from a clean, fully + * up-to-date state. + */ + protected function registerPostUpdateFunctions(): void { + $updates = $this->container->get('update.post_update_registry') + ->getPendingUpdateFunctions(); + + $this->container->get('keyvalue') + ->get('post_update') + ->set('existing_updates', $updates); + } + } /** diff --git a/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php b/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php index a1a26042637c3f7730493ee2e4937e3721804172..ea90029ca28aa001c035cb076bf868158b580dca 100644 --- a/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php +++ b/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php @@ -17,22 +17,6 @@ class PendingUpdatesValidatorTest extends PackageManagerKernelTestBase { */ protected static $modules = ['system']; - /** - * Registers all of the System module's post-update functions. - * - * Since kernel tests don't normally install modules and register their - * updates, this method makes sure that the validator is tested from a clean, - * fully up-to-date state. - */ - private function registerPostUpdateFunctions(): void { - $updates = $this->container->get('update.post_update_registry') - ->getPendingUpdateFunctions(); - - $this->container->get('keyvalue') - ->get('post_update') - ->set('existing_updates', $updates); - } - /** * Tests that no error is raised if there are no pending updates. */ diff --git a/package_manager/tests/src/Kernel/StageEventsTest.php b/package_manager/tests/src/Kernel/StageEventsTest.php index ff506bc6cc1cdf74208a6ecc1ab0e9b8b3f1e792..aa67a4b3ac214450070c966af8cc94f6bbb7d0fd 100644 --- a/package_manager/tests/src/Kernel/StageEventsTest.php +++ b/package_manager/tests/src/Kernel/StageEventsTest.php @@ -51,7 +51,8 @@ class StageEventsTest extends PackageManagerKernelTestBase implements EventSubsc $this->container->get('package_manager.stager'), $this->container->get('package_manager.committer'), $this->container->get('package_manager.cleaner'), - $this->container->get('event_dispatcher') + $this->container->get('event_dispatcher'), + $this->container->get('tempstore.shared') ); } diff --git a/package_manager/tests/src/Kernel/StageOwnershipTest.php b/package_manager/tests/src/Kernel/StageOwnershipTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b763c6ef2d6a92cb977836b91a21d23be2ce188a --- /dev/null +++ b/package_manager/tests/src/Kernel/StageOwnershipTest.php @@ -0,0 +1,136 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\package_manager\StageException; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * Tests that ownership of the stage is enforced. + * + * @group package_manger + */ +class StageOwnershipTest extends PackageManagerKernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['system', 'user']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installSchema('system', ['sequences']); + $this->installEntitySchema('user'); + $this->registerPostUpdateFunctions(); + } + + /** + * Tests only the owner of stage can perform operations, even if logged out. + */ + public function testOwnershipEnforcedWhenLoggedOut(): void { + $this->assertOwnershipIsEnforced($this->createStage(), $this->createStage()); + } + + /** + * Tests only the owner of stage can perform operations. + */ + public function testOwnershipEnforcedWhenLoggedIn(): void { + $user_1 = $this->createUser([], NULL, FALSE, ['uid' => 2]); + $this->setCurrentUser($user_1); + + $will_create = $this->createStage(); + // Rebuild the container so that the shared tempstore factory is made + // properly aware of the new current user ($user_2) before another stage + // is created. + $kernel = $this->container->get('kernel'); + $this->container = $kernel->rebuildContainer(); + $user_2 = $this->createUser(); + $this->setCurrentUser($user_2); + $this->assertOwnershipIsEnforced($will_create, $this->createStage()); + } + + /** + * Asserts that ownership is enforced across staging areas. + * + * @param \Drupal\Tests\package_manager\Kernel\TestStage $will_create + * The stage that will be created, and owned by the current user or session. + * @param \Drupal\Tests\package_manager\Kernel\TestStage $never_create + * The stage that will not be created, but should still respect the + * ownership and status of the other stage. + */ + private function assertOwnershipIsEnforced(TestStage $will_create, TestStage $never_create): void { + // Before the staging area is created, isOwnedByCurrentUser() should return + // FALSE and isAvailable() should return TRUE. + $this->assertFalse($will_create->isOwnedByCurrentUser()); + $this->assertFalse($never_create->isOwnedByCurrentUser()); + $this->assertTrue($will_create->isAvailable()); + $this->assertTrue($never_create->isAvailable()); + + $will_create->create(); + // Only the staging area that was actually created should be owned by the + // current user... + $this->assertTrue($will_create->isOwnedByCurrentUser()); + $this->assertFalse($never_create->isOwnedByCurrentUser()); + // ...but both staging areas should be considered unavailable (i.e., cannot + // be created until the existing one is destroyed first). + $this->assertFalse($will_create->isAvailable()); + $this->assertFalse($never_create->isAvailable()); + + // We should get an error if we try to create the staging area again, + // regardless of who owns it. + foreach ([$will_create, $never_create] as $stage) { + try { + $stage->create(); + $this->fail("Able to create a stage that already exists."); + } + catch (StageException $exception) { + $this->assertSame('Cannot create a new stage because one already exists.', $exception->getMessage()); + } + } + + // Only the stage's owner should be able to move it through its life cycle. + $callbacks = [ + 'require' => [ + ['vendor/lib:0.0.1'], + ], + 'apply' => [], + 'destroy' => [], + ]; + foreach ($callbacks as $method => $arguments) { + try { + $never_create->$method(...$arguments); + $this->fail("Able to call '$method' on a stage that was never created."); + } + catch (StageException $exception) { + $this->assertSame('Stage is not owned by the current user or session.', $exception->getMessage()); + } + // The call should succeed on the created stage. + $will_create->$method(...$arguments); + } + } + + /** + * Tests a stage being destroyed by a user who doesn't own it. + */ + public function testForceDestroy(): void { + $owned = $this->createStage(); + $owned->create(); + + $not_owned = $this->createStage(); + try { + $not_owned->destroy(); + $this->fail("Able to destroy a stage that we don't own."); + } + catch (StageException $exception) { + $this->assertSame('Stage is not owned by the current user or session.', $exception->getMessage()); + } + // We should be able to destroy the stage if we ignore ownership. + $not_owned->destroy(TRUE); + } + +} diff --git a/src/Form/UpdateReady.php b/src/Form/UpdateReady.php index 68e792b1638dffb06e2c4ef31660fd871b998b66..ece828ee94aaa9c2398af110183dbcb60627aebb 100644 --- a/src/Form/UpdateReady.php +++ b/src/Form/UpdateReady.php @@ -71,6 +71,11 @@ class UpdateReady extends FormBase { * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { + if (!$this->updater->isOwnedByCurrentUser()) { + $this->messenger->addError('Cannot continue the update because another Composer operation is currently in progress.'); + return $form; + } + $form['backup'] = [ '#prefix' => '<strong>', '#markup' => $this->t('Back up your database and site before you continue. <a href=":backup_url">Learn how</a>.', [':backup_url' => 'https://www.drupal.org/node/22281']), diff --git a/src/Form/UpdaterForm.php b/src/Form/UpdaterForm.php index 78b6cc44059c9ec64e9728b4f2f4237e47b75096..43ad1a01cd12454c01c55d8e4608981c2319f5c3 100644 --- a/src/Form/UpdaterForm.php +++ b/src/Form/UpdaterForm.php @@ -191,7 +191,7 @@ class UpdaterForm extends FormBase { ->getResults(SystemManager::REQUIREMENT_ERROR); if (empty($errors)) { - $form['actions'] = $this->actions(); + $form['actions'] = $this->actions($form_state); } return $form; } @@ -199,14 +199,22 @@ class UpdaterForm extends FormBase { /** * Builds the form actions. * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * * @return mixed[][] * The form's actions elements. */ - protected function actions(): array { + protected function actions(FormStateInterface $form_state): array { $actions = ['#type' => 'actions']; - if ($this->updater->hasActiveUpdate()) { - $this->messenger()->addError($this->t('Another Composer update process is currently active')); + if (!$this->updater->isAvailable()) { + // If the form has been submitted do not display this error message + // because ::deleteExistingUpdate() may run on submit. The message will + // still be displayed on form build if needed. + if (!$form_state->getUserInput()) { + $this->messenger()->addError($this->t('Cannot begin an update because another Composer operation is currently in progress.')); + } $actions['delete'] = [ '#type' => 'submit', '#value' => $this->t('Delete existing update'), diff --git a/src/Updater.php b/src/Updater.php index 302b3e87abcdb870db72270f56b94e0c78389d8e..4a1371423460543bb1f305e506380501a4518b8a 100644 --- a/src/Updater.php +++ b/src/Updater.php @@ -41,20 +41,6 @@ class Updater extends Stage { parent::__construct(...$arguments); } - /** - * Determines if there is an active update in progress. - * - * @return bool - * TRUE if there is active update, otherwise FALSE. - */ - public function hasActiveUpdate(): bool { - $staged_dir = $this->pathLocator->getStageDirectory(); - if (is_dir($staged_dir) || $this->state->get(static::PACKAGES_KEY)) { - return TRUE; - } - return FALSE; - } - /** * Begins the update. * diff --git a/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock b/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock index a98bc9cad77679e2f11d6cab1778199d4f5fbc53..67668976a3a41b6dd7a23c72c3fd9e3e126aa0f8 100644 --- a/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock +++ b/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock @@ -1,5 +1,10 @@ { "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "type": "drupal-core" + }, { "name": "drupal/test_module", "version": "1.3.0", diff --git a/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock b/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock index a5b3e1c62aea2142e215a712b998cd3d0bc360f1..ba776321961b650a90d5459d0e5441b5a3575912 100644 --- a/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock +++ b/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock @@ -1,5 +1,10 @@ { "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + }, { "name": "drupal/test_module", "version": "1.3.0", diff --git a/tests/fixtures/project_staged_validation/no_errors/active/composer.lock b/tests/fixtures/project_staged_validation/no_errors/active/composer.lock index 0eeceb0c96066f250b7f55664f6258c5914f0bef..24e46dc84c259bf9a0edf3400294e65e73488615 100644 --- a/tests/fixtures/project_staged_validation/no_errors/active/composer.lock +++ b/tests/fixtures/project_staged_validation/no_errors/active/composer.lock @@ -1,5 +1,10 @@ { "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "type": "drupal-core" + }, { "name": "drupal/test_module", "version": "1.3.0", diff --git a/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock b/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock index 93ef0ebbc5f7d459dad93e4361c0bdb7336ae38d..5b77d4cbbd893f431d2b27e18efaf489988feea0 100644 --- a/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock +++ b/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock @@ -1,5 +1,10 @@ { "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + }, { "name": "drupal/test_module", "version": "1.3.0", diff --git a/tests/fixtures/project_staged_validation/project_removed/active/composer.lock b/tests/fixtures/project_staged_validation/project_removed/active/composer.lock index ef2f849c66139908f83fe4c0579e967ade4228fe..ef86aa78d8df4942a0bcf8fe67d0561b044bfe62 100644 --- a/tests/fixtures/project_staged_validation/project_removed/active/composer.lock +++ b/tests/fixtures/project_staged_validation/project_removed/active/composer.lock @@ -1,5 +1,10 @@ { "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "type": "drupal-core" + }, { "name": "drupal/test_theme", "version": "1.3.0", diff --git a/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock b/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock index d8a60b12647d7d1c0d40a27dca5296b95d84886e..07773fab4cc96d6d4f41f2e0d0067e3fc51f4e59 100644 --- a/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock +++ b/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock @@ -1,5 +1,10 @@ { "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + }, { "name": "drupal/test_module2", "version": "1.3.1", diff --git a/tests/fixtures/project_staged_validation/version_changed/active/composer.lock b/tests/fixtures/project_staged_validation/version_changed/active/composer.lock index 5da8f50209cd3a1afca10a4572651140c9751980..36980fee6ffcbaa1fc93d2c970f8a1d951dd4d2d 100644 --- a/tests/fixtures/project_staged_validation/version_changed/active/composer.lock +++ b/tests/fixtures/project_staged_validation/version_changed/active/composer.lock @@ -1,5 +1,10 @@ { "packages": [ + { + "name": "drupal/core", + "version": "9.8.0", + "type": "drupal-core" + }, { "name": "drupal/test_module", "version": "1.3.0", diff --git a/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock b/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock index 129f0ffdc1c914e95763bb34b9c61180d58d1754..c16841c9dd296bbf7cbeab7bd5373bd020e38890 100644 --- a/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock +++ b/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock @@ -1,5 +1,10 @@ { "packages": [ + { + "name": "drupal/core", + "version": "9.8.1", + "type": "drupal-core" + }, { "name": "drupal/test_module", "version": "1.3.1", diff --git a/tests/src/Functional/FileSystemOperationsTest.php b/tests/src/Functional/FileSystemOperationsTest.php index 4d325efd3a2989a9cbd1ed03e326da04a5e6d0f7..df829312998af0947f996b710bc18a2044786c5e 100644 --- a/tests/src/Functional/FileSystemOperationsTest.php +++ b/tests/src/Functional/FileSystemOperationsTest.php @@ -76,7 +76,8 @@ class FileSystemOperationsTest extends AutomaticUpdatesFunctionalTestBase { $this->container->get('package_manager.stager'), $this->container->get('package_manager.committer'), $cleaner, - $this->container->get('event_dispatcher') + $this->container->get('event_dispatcher'), + $this->container->get('tempstore.shared') ); // Use the public and private files directories in the fake site fixture. diff --git a/tests/src/Functional/UpdateLockTest.php b/tests/src/Functional/UpdateLockTest.php new file mode 100644 index 0000000000000000000000000000000000000000..b5411aacaa4d14d3cdbc76a422ad27afd4409a06 --- /dev/null +++ b/tests/src/Functional/UpdateLockTest.php @@ -0,0 +1,77 @@ +<?php + +namespace Drupal\Tests\automatic_updates\Functional; + +/** + * Tests that only one Automatic Update operation can be performed at a time. + * + * @group automatic_updates + */ +class UpdateLockTest extends AutomaticUpdatesFunctionalTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'automatic_updates', + 'automatic_updates_test', + 'package_manager_bypass', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1.xml'); + $this->drupalLogin($this->rootUser); + $this->checkForUpdates(); + } + + /** + * Tests that only user who started an update can continue through it. + */ + public function testLock() { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + $this->setCoreVersion('9.8.0'); + $this->checkForUpdates(); + $permissions = ['administer software updates']; + $user_1 = $this->createUser($permissions); + $user_2 = $this->createUser($permissions); + + // We should be able to get partway through an update without issue. + $this->drupalLogin($user_1); + $this->drupalGet('/admin/modules/automatic-update'); + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + $assert_session->buttonExists('Continue'); + $assert_session->addressEquals('/admin/automatic-update-ready'); + + // Another user cannot show up and try to start an update, since the other + // user already started one. + $this->drupalLogin($user_2); + $this->drupalGet('/admin/modules/automatic-update'); + $assert_session->buttonNotExists('Update'); + $assert_session->pageTextContains('Cannot begin an update because another Composer operation is currently in progress.'); + + // If the current user did not start the update, they should not be able to + // continue it, either. + $this->drupalGet('/admin/automatic-update-ready'); + $assert_session->pageTextContains('Cannot continue the update because another Composer operation is currently in progress.'); + $assert_session->buttonNotExists('Continue'); + + // The user who started the update should be able to continue it. + $this->drupalLogin($user_1); + $this->drupalGet('/admin/automatic-update-ready'); + $assert_session->pageTextNotContains('Cannot continue the update because another Composer operation is currently in progress.'); + $assert_session->buttonExists('Continue'); + } + +} diff --git a/tests/src/Functional/UpdaterFormTest.php b/tests/src/Functional/UpdaterFormTest.php index c2cdefcde60ee111f1082b43468ad3d20a974273..f05a955fcffed8ea0ee5c1c8eee50d33c1f6d714 100644 --- a/tests/src/Functional/UpdaterFormTest.php +++ b/tests/src/Functional/UpdaterFormTest.php @@ -183,6 +183,8 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase { $assert_session->pageTextNotContains(static::$warningsExplanation); $page->pressButton('Update'); $this->checkForMetaRefresh(); + // If a validator flags an error, but doesn't throw, the update should still + // be halted. $assert_session->pageTextContainsOnce('An error has occurred.'); $page->clickLink('the error page'); $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]); @@ -190,11 +192,10 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase { $assert_session->pageTextNotContains($expected_results[0]->getSummary()); // ...but we should see the exception message. $assert_session->pageTextContainsOnce('The update exploded.'); - - // If a validator flags an error, but doesn't throw, the update should still - // be halted. + // If the error is thrown in PreCreateEvent the update stage will not have + // been created. + $assert_session->buttonNotExists('Delete existing update'); TestChecker1::setTestResult($expected_results, PreCreateEvent::class); - $this->deleteStagedUpdate(); $page->pressButton('Update'); $this->checkForMetaRefresh(); $assert_session->pageTextContainsOnce('An error has occurred.'); @@ -207,8 +208,6 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase { // continue. $expected_results = $this->testResults['checker_1']['1 warning']; TestChecker1::setTestResult($expected_results, PreCreateEvent::class); - $session->reload(); - $this->deleteStagedUpdate(); $page->pressButton('Update'); $this->checkForMetaRefresh(); $assert_session->pageTextContains('Ready to update'); @@ -234,13 +233,41 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase { } /** - * Deletes a staged, failed update. + * Tests deleting an existing update. + * + * @todo Add test coverage for differences between stage owner and other users + * in https://www.drupal.org/i/3248928. */ - private function deleteStagedUpdate(): void { - $session = $this->getSession(); - $session->getPage()->pressButton('Delete existing update'); - $this->assertSession()->pageTextContains('Staged update deleted'); - $session->reload(); + public function testDeleteExistingUpdate() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->setCoreVersion('9.8.0'); + $this->checkForUpdates(); + + $this->drupalGet('/admin/modules/automatic-update'); + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + + // Confirm we are on the confirmation page. + $assert_session->addressEquals('/admin/automatic-update-ready'); + $assert_session->buttonExists('Continue'); + + // Return to the start page. + $this->drupalGet('/admin/modules/automatic-update'); + $assert_session->pageTextContainsOnce('Cannot begin an update because another Composer operation is currently in progress.'); + $assert_session->buttonNotExists('Update'); + + // Delete the existing update. + $page->pressButton('Delete existing update'); + $assert_session->pageTextNotContains('Cannot begin an update because another Composer operation is currently in progress.'); + + // Ensure we can start another update after deleting the existing one. + $page->pressButton('Update'); + $this->checkForMetaRefresh(); + + // Confirm we are on the confirmation page. + $assert_session->addressEquals('/admin/automatic-update-ready'); + $assert_session->buttonExists('Continue'); } } diff --git a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php index 21d7a1d0b36cb79ef1a254357a54ad9bebe5a9bd..187c091116da0c445d7411c6cb72678658c574af 100644 --- a/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php +++ b/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\automatic_updates\Kernel\ReadinessValidation; use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\package_manager\Event\PreApplyEvent; use Drupal\package_manager\PathLocator; use Drupal\package_manager\StageException; use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase; @@ -20,6 +21,7 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase { protected static $modules = [ 'automatic_updates', 'package_manager', + 'package_manager_bypass', ]; /** @@ -47,15 +49,20 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase { private function validate(string $active_dir, string $stage_dir): array { $locator = $this->prophesize(PathLocator::class); $locator->getActiveDirectory()->willReturn($active_dir); + $locator->getProjectRoot()->willReturn($active_dir); + $locator->getVendorDirectory()->willReturn($active_dir); $locator->getStageDirectory()->willReturn($stage_dir); $this->container->set('package_manager.path_locator', $locator->reveal()); + $updater = $this->container->get('automatic_updates.updater'); + $updater->begin(['drupal' => '9.8.1']); + // The staged projects validator only runs before staged updates are // applied. Since the active and stage directories may not exist, we don't // want to invoke the other stages of the update because they may raise // errors that are outside of the scope of what we're testing here. try { - $this->container->get('automatic_updates.updater')->apply(); + $updater->apply(); return []; } catch (StageException $e) { @@ -67,11 +74,29 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase { * Tests that if an exception is thrown, the event will absorb it. */ public function testEventConsumesExceptionResults(): void { - $results = $this->validate('/fake/active/dir', '/fake/stage/dir'); + // Prepare a fake site in the virtual file system which contains valid + // Composer data. + $fixture = __DIR__ . '/../../../fixtures/fake-site'; + copy("$fixture/composer.json", 'public://composer.json'); + copy("$fixture/composer.lock", 'public://composer.lock'); + + $event_dispatcher = $this->container->get('event_dispatcher'); + // Disable the disk space validator, since it doesn't work with vfsStream. + $disk_space_validator = $this->container->get('package_manager.validator.disk_space'); + $event_dispatcher->removeSubscriber($disk_space_validator); + // Just before the staged changes are applied, delete the composer.json file + // to trigger an error. This uses the highest possible priority to guarantee + // it runs before any other subscribers. + $listener = function () { + unlink('public://composer.json'); + }; + $event_dispatcher->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX); + + $results = $this->validate('public://', '/fake/stage/dir'); $this->assertCount(1, $results); $messages = reset($results)->getMessages(); $this->assertCount(1, $messages); - $this->assertStringContainsString('Composer could not find the config file: /fake/active/dir/composer.json', (string) reset($messages)); + $this->assertStringContainsString('Composer could not find the config file: public:///composer.json', (string) reset($messages)); } /** diff --git a/tests/src/Kernel/UpdaterTest.php b/tests/src/Kernel/UpdaterTest.php index 93eb21c00c7666c9b0398b06adf1eeed0ebf6d76..1a42cb00d3ee953c6959c6d760b75c37f1495eca 100644 --- a/tests/src/Kernel/UpdaterTest.php +++ b/tests/src/Kernel/UpdaterTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\automatic_updates\Kernel; use Drupal\package_manager\PathLocator; +use Drupal\Tests\user\Traits\UserCreationTrait; use PhpTuf\ComposerStager\Domain\StagerInterface; use Prophecy\Argument; @@ -13,6 +14,8 @@ use Prophecy\Argument; */ class UpdaterTest extends AutomaticUpdatesKernelTestBase { + use UserCreationTrait; + /** * {@inheritdoc} */ @@ -21,14 +24,28 @@ class UpdaterTest extends AutomaticUpdatesKernelTestBase { 'automatic_updates_test', 'package_manager', 'package_manager_bypass', + 'system', + 'user', ]; + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('user'); + } + /** * Tests that correct versions are staged after calling ::begin(). */ public function testCorrectVersionsStaged() { $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml'); + // Create a user who will own the stage even after the container is rebuilt. + $user = $this->createUser([], NULL, TRUE, ['uid' => 2]); + $this->setCurrentUser($user); + // Point to a fake site which requires Drupal core via a distribution. The // lock file should be scanned to determine the core packages, which should // result in drupal/core-recommended being updated. @@ -48,8 +65,9 @@ class UpdaterTest extends AutomaticUpdatesKernelTestBase { $kernel = $this->container->get('kernel'); $kernel->rebuildContainer(); $this->container = $kernel->getContainer(); - // Keep using the mocked path locator. + // Keep using the mocked path locator and current user. $this->container->set('package_manager.path_locator', $locator->reveal()); + $this->setCurrentUser($user); // When we call Updater::stage(), the stored project versions should be // read from state and passed to Composer Stager's Stager service, in the