Prevent stale cart LiveComponents from mutating completed orders

This commit is contained in:
Tomasz Kaliński 2026-05-27 08:12:52 +02:00
parent 3b47d6f94e
commit bdf60d95f6
6 changed files with 309 additions and 1 deletions

View file

@ -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

View file

@ -0,0 +1,204 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Behat\Context\Ui\Shop;
use Behat\Behat\Context\Context;
use Behat\Mink\Mink;
use Doctrine\ORM\EntityManagerInterface;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\OrderCheckoutStates;
use Sylius\Component\Core\Repository\OrderRepositoryInterface;
use Sylius\Component\Order\Model\OrderInterface as BaseOrderInterface;
use Webmozart\Assert\Assert;
final readonly class StaleCartLiveComponentContext implements Context
{
public function __construct(
private Mink $mink,
private SharedStorageInterface $sharedStorage,
private OrderRepositoryInterface $orderRepository,
private EntityManagerInterface $entityManager,
) {
}
/**
* @Given I note the current order id for later assertions
*/
public function iNoteTheCurrentOrderId(): void
{
$page = $this->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");
}
}

View file

@ -505,6 +505,13 @@
<argument type="service" id="sylius.behat.element.browser" />
</service>
<service id="sylius.behat.context.ui.shop.stale_cart_live_component" class="Sylius\Behat\Context\Ui\Shop\StaleCartLiveComponentContext">
<argument type="service" id="behat.mink" />
<argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="sylius.repository.order" />
<argument type="service" id="doctrine.orm.entity_manager" />
</service>
<service id="sylius.behat.context.ui.shop.contact" class="Sylius\Behat\Context\Ui\Shop\ContactContext">
<argument type="service" id="sylius.behat.page.shop.contact" />
<argument type="service" id="sylius.behat.notification_checker.shop" />

View file

@ -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

View file

@ -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"

View file

@ -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);