From 4c120f3f05c829b1ea1c68728cce19aee7f0e058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Kali=C5=84ski?= Date: Wed, 27 May 2026 08:12:52 +0200 Subject: [PATCH] Prevent stale cart LiveComponents from mutating completed orders --- ..._prevents_completed_order_mutation.feature | 39 ++++ .../Ui/Shop/StaleCartLiveComponentContext.php | 204 ++++++++++++++++++ .../Resources/config/services/contexts/ui.xml | 7 + .../Behat/Resources/config/suites/ui.yml | 1 + .../ui/cart/stale_cart_live_component.yml | 29 +++ .../Twig/Component/Cart/FormComponent.php | 30 ++- 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 features/shop/cart/shopping_cart/stale_live_component_prevents_completed_order_mutation.feature create mode 100644 src/Sylius/Behat/Context/Ui/Shop/StaleCartLiveComponentContext.php create mode 100644 src/Sylius/Behat/Resources/config/suites/ui/cart/stale_cart_live_component.yml diff --git a/features/shop/cart/shopping_cart/stale_live_component_prevents_completed_order_mutation.feature b/features/shop/cart/shopping_cart/stale_live_component_prevents_completed_order_mutation.feature new file mode 100644 index 0000000000..0b5908557b --- /dev/null +++ b/features/shop/cart/shopping_cart/stale_live_component_prevents_completed_order_mutation.feature @@ -0,0 +1,39 @@ +@stale_live_component +Feature: Stale cart LiveComponent cannot mutate a completed order + In order to protect completed orders from data corruption + As a Developer + I want the cart LiveComponent to be safe against stale browser state + + Background: + Given the store operates on a single channel in "United States" + And the store has a product "Sylius T-Shirt" priced at "$19.99" + + @ui @javascript + Scenario: Clearing cart from a stale page does not delete a completed order + Given I added product "Sylius T-Shirt" to the cart + And I check the details of my cart + And I note the current order id for later assertions + When the order is completed in the background + And I clear my cart from the stale page without reloading + Then the completed order should still exist in the database + And the order checkout state should be "completed" + + @ui @javascript + Scenario: Removing an item from a stale page does not mutate a completed order + Given I added product "Sylius T-Shirt" to the cart + And I check the details of my cart + And I note the current order id for later assertions + When the order is completed in the background + And I remove first item from cart from the stale page without reloading + Then the completed order should still exist in the database + And the order should still have 1 item + + @ui @javascript + Scenario: Increasing item quantity on a stale page does not mutate a completed order + Given I added product "Sylius T-Shirt" to the cart + And I check the details of my cart + And I note the current order id for later assertions + When the order is completed in the background + And I increase the quantity of the first cart item to 2 on the stale page without reloading + Then the completed order should still exist in the database + And the order item quantity should still be 1 diff --git a/src/Sylius/Behat/Context/Ui/Shop/StaleCartLiveComponentContext.php b/src/Sylius/Behat/Context/Ui/Shop/StaleCartLiveComponentContext.php new file mode 100644 index 0000000000..272079acfd --- /dev/null +++ b/src/Sylius/Behat/Context/Ui/Shop/StaleCartLiveComponentContext.php @@ -0,0 +1,204 @@ +mink->getSession()->getPage(); + $component = $page->find('css', '[data-live-name-value="sylius_shop:cart:form"]'); + Assert::notNull($component, 'Cart LiveComponent root element not found on page.'); + + $rawProps = $component->getAttribute('data-live-props-value'); + Assert::notNull($rawProps, 'data-live-props-value attribute missing on cart form LiveComponent.'); + + $props = json_decode($rawProps, true); + $orderId = $props['resource'] ?? null; + Assert::notNull($orderId, 'Order ID not found in dehydrated LiveComponent props.'); + + $this->sharedStorage->set('stale_order_id', (int) $orderId); + } + + /** + * @When the order is completed in the background + */ + public function theOrderIsCompletedInTheBackground(): void + { + $orderId = $this->sharedStorage->get('stale_order_id'); + + /** @var OrderInterface|null $order */ + $order = $this->orderRepository->find($orderId); + Assert::notNull($order, sprintf('Order #%d not found.', $orderId)); + + $order->setCheckoutState(OrderCheckoutStates::STATE_COMPLETED); + $order->setState(BaseOrderInterface::STATE_NEW); + + $this->entityManager->flush(); + } + + /** + * @When I clear my cart from the stale page without reloading + */ + public function iClearMyCartFromTheStalePage(): void + { + $page = $this->mink->getSession()->getPage(); + + $button = $page->find('css', '[data-test-clear-cart]') + ?? $page->find('css', '[data-live-action-param="clearCart"]'); + + Assert::notNull($button, 'Clear cart button not found on the stale page.'); + + $button->click(); + + $this->waitForLiveComponent(); + } + + /** + * @When I remove first item from cart from the stale page without reloading + */ + public function iRemoveFirstItemFromCartFromTheStalePageWithoutReloading(): void + { + $page = $this->mink->getSession()->getPage(); + + $button = $page->find('css', '[data-test-remove-cart-item]') + ?? $page->find('css', '[data-live-action-param="removeItem"]'); + + Assert::notNull($button, 'Remove item button not found on the stale page.'); + + $button->click(); + + $this->waitForLiveComponent(); + } + + /** + * @Then the completed order should still exist in the database + */ + public function theCompletedOrderShouldStillExistInTheDatabase(): void + { + $orderId = $this->sharedStorage->get('stale_order_id'); + + // Clear the identity map to bypass any cached entity and force a real SELECT. + $this->entityManager->clear(); + + $order = $this->orderRepository->find($orderId); + + Assert::notNull( + $order, + sprintf('Order #%d was deleted. The stale LiveComponent action must not remove a completed order.', $orderId), + ); + } + + /** + * @Then the order checkout state should be :expectedState + */ + public function theOrderCheckoutStateShouldBe(string $expectedState): void + { + $orderId = $this->sharedStorage->get('stale_order_id'); + $this->entityManager->clear(); + + /** @var OrderInterface|null $order */ + $order = $this->orderRepository->find($orderId); + Assert::notNull($order); + + Assert::same( + $order->getCheckoutState(), + $expectedState, + sprintf('Checkout state: expected "%s" but got "%s".', $expectedState, $order->getCheckoutState()), + ); + } + + /** + * @When I increase the quantity of the first cart item to :quantity on the stale page without reloading + */ + public function iIncreaseTheQuantityOfTheFirstCartItemOnTheStalePage(int $quantity): void + { + $page = $this->mink->getSession()->getPage(); + + $input = $page->find('css', '[data-test-cart-item-quantity]'); + Assert::notNull($input, 'Cart item quantity input not found on the stale page.'); + + $input->setValue((string) $quantity); + + $this->waitForLiveComponent(); + } + + /** + * @Then the order item quantity should still be :quantity + */ + public function theOrderItemQuantityShouldStillBe(int $quantity): void + { + $orderId = $this->sharedStorage->get('stale_order_id'); + $this->entityManager->clear(); + + /** @var OrderInterface|null $order */ + $order = $this->orderRepository->find($orderId); + Assert::notNull($order); + + $firstItem = $order->getItems()->first(); + Assert::notFalse($firstItem, 'The order has no items.'); + + Assert::same( + $firstItem->getQuantity(), + $quantity, + sprintf('Expected item quantity %d but got %d.', $quantity, $firstItem->getQuantity()), + ); + } + + /** + * @Then the order should still have :count item(s) + */ + public function theOrderShouldStillHaveItems(int $count): void + { + $orderId = $this->sharedStorage->get('stale_order_id'); + $this->entityManager->clear(); + + /** @var OrderInterface|null $order */ + $order = $this->orderRepository->find($orderId); + Assert::notNull($order); + + Assert::count( + $order->getItems(), + $count, + sprintf('Expected %d item(s) but found %d.', $count, $order->getItems()->count()), + ); + } + + private function waitForLiveComponent(int $timeoutMs = 5000): void + { + $this->mink->getSession()->wait(2000, "document.querySelector('[data-live-loading]') !== null"); + $this->mink->getSession()->wait($timeoutMs, "document.querySelector('[data-live-loading]') === null"); + } +} diff --git a/src/Sylius/Behat/Resources/config/services/contexts/ui.xml b/src/Sylius/Behat/Resources/config/services/contexts/ui.xml index 139269e6be..0517f82321 100644 --- a/src/Sylius/Behat/Resources/config/services/contexts/ui.xml +++ b/src/Sylius/Behat/Resources/config/services/contexts/ui.xml @@ -505,6 +505,13 @@ + + + + + + + diff --git a/src/Sylius/Behat/Resources/config/suites/ui.yml b/src/Sylius/Behat/Resources/config/suites/ui.yml index 98baf39943..f8199c165b 100644 --- a/src/Sylius/Behat/Resources/config/suites/ui.yml +++ b/src/Sylius/Behat/Resources/config/suites/ui.yml @@ -17,6 +17,7 @@ imports: - ui/admin/security.yml - ui/cart/accessing_cart.yaml - ui/cart/shopping_cart.yml + - ui/cart/stale_cart_live_component.yml - ui/channel/channels.yml - ui/channel/managing_channels.yml - ui/channel/products_accessibility_in_multiple_channels.yml diff --git a/src/Sylius/Behat/Resources/config/suites/ui/cart/stale_cart_live_component.yml b/src/Sylius/Behat/Resources/config/suites/ui/cart/stale_cart_live_component.yml new file mode 100644 index 0000000000..e07c039113 --- /dev/null +++ b/src/Sylius/Behat/Resources/config/suites/ui/cart/stale_cart_live_component.yml @@ -0,0 +1,29 @@ +# This file is part of the Sylius package. +# (c) Sylius Sp. z o.o. + +default: + suites: + ui_stale_cart_live_component: + contexts: + - sylius.behat.context.hook.bad_gateway + - sylius.behat.context.hook.doctrine_orm + - sylius.behat.context.hook.guest_cart + - sylius.behat.context.hook.session + + - sylius.behat.context.transform.channel + - sylius.behat.context.transform.lexical + - sylius.behat.context.transform.product + - sylius.behat.context.transform.shared_storage + + - sylius.behat.context.setup.cart + - sylius.behat.context.setup.channel + - sylius.behat.context.setup.currency + - sylius.behat.context.setup.product + - sylius.behat.context.setup.zone + + - sylius.behat.context.ui.shop.cart + - sylius.behat.context.ui.shop.product + - sylius.behat.context.ui.shop.stale_cart_live_component + + filters: + tags: "@stale_live_component&&@ui" diff --git a/src/Sylius/Bundle/ShopBundle/Twig/Component/Cart/FormComponent.php b/src/Sylius/Bundle/ShopBundle/Twig/Component/Cart/FormComponent.php index c111062a34..da575e0b24 100644 --- a/src/Sylius/Bundle/ShopBundle/Twig/Component/Cart/FormComponent.php +++ b/src/Sylius/Bundle/ShopBundle/Twig/Component/Cart/FormComponent.php @@ -17,8 +17,10 @@ use Doctrine\Persistence\ObjectManager; use Sylius\Bundle\UiBundle\Twig\Component\ResourceFormComponentTrait; use Sylius\Bundle\UiBundle\Twig\Component\TemplatePropTrait; use Sylius\Component\Core\Model\OrderInterface; +use Sylius\Component\Core\OrderCheckoutStates; use Sylius\Component\Core\Repository\OrderRepositoryInterface; use Sylius\Component\Order\SyliusCartEvents; +use Sylius\Resource\Model\ResourceInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\GenericEvent; use Symfony\Component\Form\FormFactoryInterface; @@ -56,10 +58,28 @@ class FormComponent $this->initialize($orderRepository, $formFactory, $resourceClass, $formClass); } + public function hydrateResource(mixed $value): ?ResourceInterface + { + if (empty($value)) { + return $this->createResource(); + } + + /** @var OrderInterface|null $order */ + $order = $this->repository->find($value); + + if (!$order instanceof OrderInterface + || $order->getCheckoutState() === OrderCheckoutStates::STATE_COMPLETED + ) { + return $this->createResource(); + } + + return $order; + } + #[PreReRender(priority: -100)] public function saveCart(): void { - if ($this->shouldSaveCart) { + if ($this->shouldSaveCart && $this->resource?->getId() !== null) { $form = $this->getForm(); if ($form->isValid()) { $this->eventDispatcher->dispatch(new GenericEvent($form->getData()), SyliusCartEvents::CART_CHANGE); @@ -72,6 +92,10 @@ class FormComponent #[LiveAction] public function removeItem(#[LiveArg] int $index): void { + if ($this->resource?->getId() === null) { + return; + } + $data = $this->formValues['items']; unset($data[$index]); $this->formValues['items'] = array_values($data); @@ -91,6 +115,10 @@ class FormComponent #[LiveAction] public function clearCart(): void { + if ($this->resource?->getId() === null) { + return; + } + $this->formValues['items'] = []; $this->eventDispatcher->dispatch(new GenericEvent($this->resource), SyliusCartEvents::CART_CLEAR); $this->manager->remove($this->resource);