bug #13676 [Cart] Fix retrieving/overriding cart of logged in user by guest (SirDomin, GSadee)

This PR was merged into the 1.10 branch.

Discussion
----------

| Q               | A
| --------------- | -----
| Branch?         | 1.10
| Bug fix?        | yes
| New feature?    | no
| BC breaks?      | no
| Deprecations?   | no
| Related tickets | replaces https://github.com/Sylius/Sylius/pull/13603 and https://github.com/Sylius/Sylius/pull/13634
| License         | MIT

<!--
 - Bug fixes must be submitted against the 1.10 or 1.11 branch(the lowest possible)
 - Features and deprecations must be submitted against the master branch
 - Make sure that the correct base branch is set

 To be sure you are not breaking any Backward Compatibilities, check the documentation:
 https://docs.sylius.com/en/latest/book/organization/backward-compatibility-promise.html
-->


Commits
-------

570fe09de1 [Behat][UI] Prevent anonymous user to claim logged user cart
f1c7530780 [Behat] Add session manager service
8d588731f9 [Cart] Return first cart for customer from repository
665738674c [Core] Mark cart as placed by guest
e0c7302dff Add info to UPGRADE file
fcd58556cd [Behat] Add additional scenarios to cover claiming cart by a proper user after login/registration
2ff78cfed4 [Cart] Revert change for returning first cart for customer from repository
e1fa6f6ced [Order] Rename field guest to byGuest
b0401a314a [Cart] Improve setting byGuest flag in ShopBasedCartContext
0d65e9a998 [Cart] Set byGuest flag during cart blaming
b9c36f7f82 [ADR] Describe the solution of retrieving cart problem
314cd103e6 [Migrations] Add missing mysql check for migrations
7f9154b1c9 [Cart] Improvements to fixing problem with cart after PR review
dad642ac91 [Cart] Extract resolving byGuest flag to a separate service
cdd57b196f [ADR] Improve the solution of overriding cart problem
eb0a597746 [Order] Rename field byGuest to createdByGuest
This commit is contained in:
Adam Kasperczak 2022-02-24 10:23:14 +01:00 committed by GitHub
commit 0a77a6d6af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 770 additions and 9 deletions

View file

@ -1,3 +1,11 @@
# UPGRADE FROM `v1.10.8` TO `v1.10.10`
1. Field `createdByGuest` has been added to `Sylius\Component\Core\Model\Order`, this change will allow us to distinguish carts
between guests and logged in customers.
2. Not passing `createdByGuestFlagResolver` through constructor in `Sylius\Component\Core\Cart\Context\ShopBasedCartContext`
is deprecated in Sylius 1.10.9 and it will be prohibited in Sylius 2.0.
# UPGRADE FROM `v1.10.x` TO `v1.10.8`
1. Update `payum/payum` to `^1.7` and execute Doctrine Migrations

View file

@ -0,0 +1,60 @@
# Using cart by guest and logged in customer
* Status: accepted
* Date: 2022-02-22
## Context and Problem Statement
Cart and its processing is one of the key aspects of Sylius. It turned out that it has a vulnerability and there is
possible for an anonymous user to override the cart of logged in customer by using only its email. This is because
when entering an email, during addressing step, the customer with this email is assigned to the cart and from then,
there is no simple way to distinguish between the carts created by the guest and the logged in user. The question is
how should we distinguish the carts to solve the vulnerability.
## Decision Drivers
Provided solution should:
* solve the initial problem with overriding cart of logged in customer by anonymous user
* not break a backward compatibility, both code and business behaviour
## Considered Options
### Forcing logging in during checkout
* Good, because it solves the initial problem
* Bad, because it changes the current expected behaviour in checkout
* Bad, because it breaks a backward compatibility from the business perspective
### Changing priority of cart contexts
* Good, because it solves the initial problem
* Bad, because it does not solve the case when a logged in customer does not have a cart
* Bad, because it changes the current expected behaviour with keeping cart after logging in
* Bad, because it breaks a backward compatibility from the business perspective
### Introducing flag on order entity to mark order created by guest
Adding a flag to order entity allows us to distinguish carts created by guest or by logged in customer. To mark cart
as soon as possible, to solve all cases of the initial problem, the flag needs to be set in Sylius\Component\Core\Cart\Context\ShopBasedCartContext,
where the customer of logged in user is also set on cart. This context is definitely not a proper service to do such operations,
however it is consistent with current approach in Sylius. Setting this value only after logging in would not be enough,
as it doesn't resolve the situation when the already logged in user creates a cart. It is similar with setting flag after
the addressing step, it doesn't resolve the problem as the cart of logged in user is marked too late and before addressing
the cart couldn't be distinguished to whom it belongs.
* Good, because it solves the initial problem
* Good, because it does not break a backward compatibility
* Good, because it can be used to additional features in the future
* Bad, because it requires to set the flag in Sylius\Component\Core\Cart\Context\ShopBasedCartContext
## Decision Outcome
Chosen option: **"Introducing flag on order entity to mark order created by guest"**
For now, it is the most straightforward solution, that resolves the initial problem, does not introduce any BC break
and it is the solution that could be used to additional features in the future.
## References
* [Approach with changing priorities](https://github.com/Sylius/Sylius/pull/13603)
* [Chosen approach with introducing flag](https://github.com/Sylius/Sylius/pull/13676)

View file

@ -33,6 +33,10 @@ default:
chrome:
api_url: http://127.0.0.1:9222
validate_certificate: false
chrome_headless_second_session:
chrome:
api_url: http://127.0.0.1:9222
validate_certificate: false
chrome:
selenium2:
browser: chrome

View file

@ -0,0 +1,72 @@
@checkout
Feature: Preventing from claiming cart of a wrong user
In order to make the checkout cart available only for user who owns the cart
As a Customer
I want to be able to checkout with my previous cart when someone used my email in checkout
Background:
Given the store operates on a single channel in "United States"
And the store has a product "PHP T-Shirt" priced at "$20.00"
And the store has a product "Kotlin T-Shirt" priced at "$30.00"
And the store has a product "Symfony T-Shirt" priced at "$100.00"
And the store has a product "Sylius T-Shirt" priced at "$150.00"
And the store ships everywhere for free
And the store allows paying offline
And there is a user "robb@stark.com" identified by "KingInTheNorth"
@ui @no-api
Scenario: Preventing anonymous user from claiming cart of logged in user
Given I am logged in as "robb@stark.com"
And I have product "PHP T-Shirt" in the cart
When an anonymous user in another browser adds products "PHP T-Shirt" and "Kotlin T-Shirt" to the cart
And they complete addressing step with email "robb@stark.com" and "United States" based billing address
And they add product "Symfony T-Shirt" to the cart
Then their cart total should be "$150.00"
@ui @no-api
Scenario: Preventing anonymous user from claiming cart of logged in user
Given I am logged in as "robb@stark.com"
And I have product "PHP T-Shirt" in the cart
When an anonymous user in another browser adds products "PHP T-Shirt" and "Kotlin T-Shirt" to the cart
And they complete addressing step with email "robb@stark.com" and "United States" based billing address
And they add product "Symfony T-Shirt" to the cart
And I view my cart in the previous session
Then there should be one item in my cart
And my cart's total should be "$20.00"
@ui @no-api
Scenario: Preventing anonymous user from claiming cart of logged in user
Given I have product "PHP T-Shirt" in the cart
When I sign in with email "robb@stark.com" and password "KingInTheNorth"
And I log out
And an anonymous user in another browser adds products "PHP T-Shirt" and "Kotlin T-Shirt" to the cart
And they complete addressing step with email "robb@stark.com" and "United States" based billing address
And they add product "Symfony T-Shirt" to the cart
And I sign in again with email "robb@stark.com" and password "KingInTheNorth" in the previous session
And I see the summary of my previous cart
Then there should be one item in my cart
And my cart's total should be "$20.00"
@ui @no-api
Scenario: Preventing anonymous user from claiming cart of logged in user
Given on this channel account verification is not required
And I have product "PHP T-Shirt" in the cart
When I register with email "eddard@stark.com" and password "handOfTheKing"
And I log out
And an anonymous user in another browser adds products "PHP T-Shirt" and "Kotlin T-Shirt" to the cart
And they complete addressing step with email "robb@stark.com" and "United States" based billing address
And they add product "Symfony T-Shirt" to the cart
And I sign in again with email "eddard@stark.com" and password "handOfTheKing" in the previous session
And I see the summary of my previous cart
Then there should be one item in my cart
And my cart's total should be "$20.00"
@ui @no-api
Scenario: Preventing logged in user from claiming cart of anonymous user
Given an anonymous user added product "Kotlin T-Shirt" to the cart
And they have completed addressing step with email "robb@stark.com" and "United States" based billing address
When I log in as "robb@stark.com"
And I add product "Sylius T-Shirt" to the cart
And I view my cart in the previous session
Then there should be one item in my cart
And my cart's total should be "$150.00"

View file

@ -44,6 +44,7 @@ final class ShopSecurityContext implements Context
/**
* @Given I am logged in as :email
* @When I log in as :email
*/
public function iAmLoggedInAs(string $email): void
{

View file

@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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 Sylius\Behat\Element\Shop\Account\RegisterElementInterface;
use Sylius\Behat\Page\Shop\Account\LoginPageInterface;
use Sylius\Behat\Page\Shop\Account\RegisterPageInterface;
final class AuthorizationContext implements Context
{
private LoginPageInterface $loginPage;
private RegisterPageInterface $registerPage;
private RegisterElementInterface $registerElement;
public function __construct(
LoginPageInterface $loginPage,
RegisterPageInterface $registerPage,
RegisterElementInterface $registerElement
) {
$this->loginPage = $loginPage;
$this->registerPage = $registerPage;
$this->registerElement = $registerElement;
}
/**
* @When I sign in with email :email and password :password
* @When I sign in again with email :email and password :password in the previous session
*/
public function iSignInWithEmailAndPassword(string $email, string $password): void
{
$this->loginPage->open();
$this->loginPage->specifyUsername($email);
$this->loginPage->specifyPassword($password);
$this->loginPage->logIn();
}
/**
* @When I register with email :email and password :password
*/
public function iRegisterWithEmailAndPassword(string $email, string $password): void
{
$this->registerPage->open();
$this->registerElement->specifyEmail($email);
$this->registerElement->specifyPassword($password);
$this->registerElement->verifyPassword($password);
$this->registerElement->specifyFirstName('Carrot');
$this->registerElement->specifyLastName('Ironfoundersson');
$this->registerElement->register();
}
}

View file

@ -19,6 +19,7 @@ use Sylius\Behat\NotificationType;
use Sylius\Behat\Page\Shop\Cart\SummaryPageInterface;
use Sylius\Behat\Page\Shop\Product\ShowPageInterface;
use Sylius\Behat\Service\NotificationCheckerInterface;
use Sylius\Behat\Service\SessionManagerInterface;
use Sylius\Behat\Service\SharedStorageInterface;
use Sylius\Component\Product\Model\ProductInterface;
use Sylius\Component\Product\Model\ProductOptionInterface;
@ -34,16 +35,20 @@ final class CartContext implements Context
private NotificationCheckerInterface $notificationChecker;
private SessionManagerInterface $sessionManager;
public function __construct(
SharedStorageInterface $sharedStorage,
SummaryPageInterface $summaryPage,
ShowPageInterface $productShowPage,
NotificationCheckerInterface $notificationChecker
NotificationCheckerInterface $notificationChecker,
SessionManagerInterface $sessionManager
) {
$this->sharedStorage = $sharedStorage;
$this->summaryPage = $summaryPage;
$this->productShowPage = $productShowPage;
$this->notificationChecker = $notificationChecker;
$this->sessionManager = $sessionManager;
}
/**
@ -97,6 +102,7 @@ final class CartContext implements Context
/**
* @Then the grand total value should be :total
* @Then my cart total should be :total
* @Then their cart total should be :total
*/
public function myCartTotalShouldBe($total)
{
@ -244,13 +250,16 @@ final class CartContext implements Context
}
/**
* @Given /^an anonymous user added (product "([^"]+)") to the cart$/
* @Given /^I (?:add|added) (this product) to the cart$/
* @Given /^I have (product "[^"]+") added to the cart$/
* @Given I added product :product to the cart
* @Given he added product :product to the cart
* @Given /^I (?:have|had) (product "[^"]+") in the cart$/
* @Given the customer added :product product to the cart
* @Given /^I (?:add|added) ("[^"]+" product) to the (cart)$/
* @When I add product :product to the cart
* @When they add product :product to the cart
*/
public function iAddProductToTheCart(ProductInterface $product): void
{
@ -271,6 +280,18 @@ final class CartContext implements Context
}
}
/**
* @When /^an anonymous user in another browser adds (products "([^"]+)" and "([^"]+)") to the cart$/
*/
public function anonymousUserAddsMultipleProductsToTheCart(array $products): void
{
$this->sessionManager->changeSession();
foreach ($products as $product) {
$this->iAddProductToTheCart($product);
}
}
/**
* @When I add :variantName variant of product :product to the cart
* @When /^I add "([^"]+)" variant of (this product) to the cart$/
@ -364,6 +385,16 @@ final class CartContext implements Context
Assert::true($this->summaryPage->hasItemWithCode($variantCode));
}
/**
* @When I view my cart in the previous session
*/
public function iViewMyCartInPreviousSession(): void
{
$this->sessionManager->restorePreviousSession();
$this->summaryPage->open();
}
/**
* @Given I have :product with :productOption :productOptionValue in the cart
* @Given I have product :product with product option :productOption :productOptionValue in the cart

View file

@ -74,7 +74,9 @@ final class CheckoutAddressingContext implements Context
/**
* @Given /^I have completed addressing step with email "([^"]+)" and ("[^"]+" based billing address)$/
* @Given /^they have completed addressing step with email "([^"]+)" and ("[^"]+" based billing address)$/
* @When /^I complete addressing step with email "([^"]+)" and ("[^"]+" based billing address)$/
* @When /^they complete addressing step with email "([^"]+)" and ("[^"]+" based billing address)$/
*/
public function iCompleteAddressingStepWithEmail(string $email, AddressInterface $address): void
{

View file

@ -55,6 +55,12 @@
<argument type="string">shop</argument>
</service>
<service id="Sylius\Behat\Service\SessionManagerInterface" class="Sylius\Behat\Service\SessionManager" public="false">
<argument type="service" id="behat.mink" />
<argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="sylius.behat.shop_security" />
</service>
<service id="sylius.behat.shared_security" class="Sylius\Behat\Service\SharedSecurityService" public="false">
<argument type="service" id="sylius.behat.admin_security" />
</service>

View file

@ -415,11 +415,18 @@
<argument type="service" id="sylius.behat.notification_checker" />
</service>
<service id="sylius.behat.context.ui.shop.authorization" class="Sylius\Behat\Context\Ui\Shop\AuthorizationContext">
<argument type="service" id="sylius.behat.page.shop.account.login" />
<argument type="service" id="sylius.behat.page.shop.account.register" />
<argument type="service" id="sylius.behat.element.shop.account.register" />
</service>
<service id="sylius.behat.context.ui.shop.cart" class="Sylius\Behat\Context\Ui\Shop\CartContext">
<argument type="service" id="sylius.behat.shared_storage" />
<argument type="service" id="sylius.behat.page.shop.cart_summary" />
<argument type="service" id="sylius.behat.page.shop.product.show" />
<argument type="service" id="sylius.behat.notification_checker" />
<argument type="service" id="Sylius\Behat\Service\SessionManagerInterface"/>
</service>
<service id="sylius.behat.context.ui.shop.contact" class="Sylius\Behat\Context\Ui\Shop\ContactContext">

View file

@ -49,6 +49,7 @@ default:
- sylius.behat.context.ui.channel
- sylius.behat.context.ui.email
- sylius.behat.context.ui.shop.address_book
- sylius.behat.context.ui.shop.authorization
- sylius.behat.context.ui.shop.cart
- sylius.behat.context.ui.shop.checkout
- sylius.behat.context.ui.shop.checkout.addressing
@ -60,6 +61,7 @@ default:
- sylius.behat.context.ui.shop.currency
- sylius.behat.context.ui.shop.homepage
- sylius.behat.context.ui.shop.locale
- sylius.behat.context.ui.user
filters:
tags: "@checkout&&@ui"

View file

@ -0,0 +1,84 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\Service;
use Behat\Mink\Mink;
use Symfony\Component\Security\Core\Exception\TokenNotFoundException;
final class SessionManager implements SessionManagerInterface
{
private const SESSION_CHROME_HEADLESS_SECOND = 'chrome_headless_second_session';
private Mink $mink;
private SharedStorageInterface $sharedStorage;
private SecurityServiceInterface $securityService;
public function __construct(Mink $mink, SharedStorageInterface $sharedStorage, SecurityServiceInterface $securityService)
{
$this->mink = $mink;
$this->sharedStorage = $sharedStorage;
$this->securityService = $securityService;
}
public function changeSession(): void
{
$sessionName = self::SESSION_CHROME_HEADLESS_SECOND;
$this->saveAndRestartSession($sessionName);
if ($this->sharedStorage->has($this->getKeyForToken($sessionName))) {
$this->securityService->restoreToken($this->sharedStorage->get($this->getKeyForToken($sessionName)));
}
}
public function restorePreviousSession(): void
{
if (!$this->sharedStorage->has('behat_previous_session_name')) {
return;
}
/** @var string $sessionName */
$sessionName = $this->sharedStorage->get('behat_previous_session_name');
$this->saveAndRestartSession($sessionName);
if ($this->sharedStorage->has($this->getKeyForToken($sessionName))) {
$this->securityService->restoreToken($this->sharedStorage->get($this->getKeyForToken($sessionName)));
}
}
private function saveAndRestartSession(string $newSessionName): void
{
/** @var string $previousSessionName */
$previousSessionName = $this->mink->getDefaultSessionName();
$this->sharedStorage->set('behat_previous_session_name', $previousSessionName);
try {
$token = $this->securityService->getCurrentToken();
$this->sharedStorage->set($this->getKeyForToken($previousSessionName), $token);
} catch (TokenNotFoundException $exception) {
}
$this->mink->setDefaultSessionName($newSessionName);
$this->mink->restartSessions();
}
private function getKeyForToken(string $sessionName): string
{
return sprintf('behat_previous_session_token_%s', $sessionName);
}
}

View file

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\Service;
interface SessionManagerInterface
{
public function changeSession(): void;
public function restorePreviousSession(): void;
}

View file

@ -0,0 +1,131 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Behat\Service;
use Behat\Mink\Mink;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Behat\Service\SecurityServiceInterface;
use Sylius\Behat\Service\SessionManagerInterface;
use Sylius\Behat\Service\SharedStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final class SessionManagerSpec extends ObjectBehavior
{
function let(Mink $mink, SharedStorageInterface $sharedStorage, SecurityServiceInterface $securityService)
{
$this->beConstructedWith($mink, $sharedStorage, $securityService);
}
function it_implements_session_service_interface(): void
{
$this->shouldImplement(SessionManagerInterface::class);
}
function it_changes_session_and_does_not_restore_session_token_if_session_was_not_called_before(
Mink $mink,
SharedStorageInterface $sharedStorage,
SecurityServiceInterface $securityService,
TokenInterface $token
): void {
$mink->getDefaultSessionName()->willReturn('default_session');
$securityService->getCurrentToken()->willReturn($token);
$token->__toString()->willReturn('{JSON_TOKEN}');
$sharedStorage->set('behat_previous_session_name', 'default_session')->shouldBeCalled();
$sharedStorage->set('behat_previous_session_token_default_session', $token)->shouldBeCalled();
$mink->setDefaultSessionName('chrome_headless_second_session')->shouldBeCalled();
$mink->restartSessions()->shouldBeCalled();
$sharedStorage->has('behat_previous_session_token_chrome_headless_second_session')->willReturn(false);
$securityService->restoreToken(Argument::any())->shouldNotBeCalled();
$this->changeSession();
}
function it_changes_session_and_restores_session_token_if_session_was_called_before(
Mink $mink,
SharedStorageInterface $sharedStorage,
SecurityServiceInterface $securityService,
TokenInterface $token,
TokenInterface $previousToken
): void {
$mink->getDefaultSessionName()->willReturn('default_session');
$securityService->getCurrentToken()->willReturn($token);
$token->__toString()->willReturn('{JSON_TOKEN}');
$sharedStorage->set('behat_previous_session_name', 'default_session')->shouldBeCalled();
$sharedStorage->set('behat_previous_session_token_default_session', $token)->shouldBeCalled();
$mink->setDefaultSessionName('chrome_headless_second_session')->shouldBeCalled();
$mink->restartSessions()->shouldBeCalled();
$sharedStorage->has('behat_previous_session_token_chrome_headless_second_session')->willReturn(true);
$sharedStorage->get('behat_previous_session_token_chrome_headless_second_session')->willReturn($previousToken);
$securityService->restoreToken($previousToken)->shouldBeCalled();
$this->changeSession();
}
function it_restores_session_and_token(
Mink $mink,
SharedStorageInterface $sharedStorage,
SecurityServiceInterface $securityService,
TokenInterface $token,
TokenInterface $defaultToken
): void {
$sharedStorage->has('behat_previous_session_name')->willReturn(true);
$sharedStorage->get('behat_previous_session_name')->willReturn('previous_session');
$mink->getDefaultSessionName()->willReturn('default_session');
$defaultToken->__toString()->willReturn('{JSON_DEFAULT_TOKEN}');
$sharedStorage->set('behat_previous_session_name', 'default_session')->shouldBeCalled();
$sharedStorage->set('behat_previous_session_token_default_session', $defaultToken)->shouldBeCalled();
$mink->setDefaultSessionName('previous_session')->shouldBeCalled();
$mink->restartSessions()->shouldBeCalled();
$securityService->getCurrentToken()->willReturn($defaultToken);
$sharedStorage->has('behat_previous_session_token_previous_session')->willReturn(true);
$sharedStorage->get('behat_previous_session_token_previous_session')->willReturn($token);
$securityService->restoreToken($token)->shouldBeCalled();
$this->restorePreviousSession();
}
function it_does_not_restore_session_and_token_if_previous_session_was_never_called(
Mink $mink,
SharedStorageInterface $sharedStorage,
SecurityServiceInterface $securityService,
TokenInterface $token
): void {
$sharedStorage->has('behat_previous_session_name')->willReturn(false);
$mink->getDefaultSessionName()->shouldNotBeCalled();
$sharedStorage->set('behat_previous_session_name', 'default_session')->shouldNotBeCalled();
$mink->setDefaultSessionName('previous_session')->shouldNotBeCalled();
$mink->restartSessions()->shouldNotBeCalled();
$securityService->restoreToken($token)->shouldNotBeCalled();
$this->restorePreviousSession();
}
}

View file

@ -15,7 +15,6 @@ namespace Sylius\Bundle\CoreBundle\Doctrine\ORM;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\OrderBundle\Doctrine\ORM\OrderRepository as BaseOrderRepository;
use Sylius\Component\Core\Model\ChannelInterface;
@ -191,9 +190,11 @@ class OrderRepository extends BaseOrderRepository implements OrderRepositoryInte
->andWhere('o.channel = :channel')
->andWhere('o.customer = :customer')
->andWhere('o.items IS NOT EMPTY')
->andWhere('o.createdByGuest = :createdByGuest')
->setParameter('state', OrderInterface::STATE_CART)
->setParameter('channel', $channel)
->setParameter('customer', $customer)
->setParameter('createdByGuest', false)
->addOrderBy('o.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Migrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20220210135918 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add created_by_guest field to mark orders made by guests';
}
public function up(Schema $schema): void
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE sylius_order ADD created_by_guest TINYINT(1) DEFAULT \'1\' NOT NULL');
$this->addSql('UPDATE sylius_order o SET o.created_by_guest = 0 WHERE o.customer_id IN (SELECT customer_id FROM sylius_shop_user)');
}
public function down(Schema $schema): void
{
$this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.');
$this->addSql('ALTER TABLE sylius_order DROP created_by_guest');
}
}

View file

@ -12,6 +12,11 @@
<field type="string" name="checkoutState" column="checkout_state" />
<field type="string" name="paymentState" column="payment_state" />
<field type="string" name="shippingState" column="shipping_state" />
<field type="boolean" name="createdByGuest" column="created_by_guest" >
<options>
<option name="default">1</option>
</options>
</field>
<many-to-one field="channel" target-entity="Sylius\Component\Channel\Model\ChannelInterface">
<join-column name="channel_id" referenced-column-name="id" nullable="true" />

View file

@ -245,5 +245,9 @@
</service>
<service id="security.authentication_manager" alias="security.authentication.manager" />
<service id="Sylius\Component\Core\Cart\Resolver\CreatedByGuestFlagResolverInterface" class="Sylius\Component\Core\Cart\Resolver\CreatedByGuestFlagResolver">
<argument type="service" id="security.token_storage" />
</service>
</services>
</container>

View file

@ -36,6 +36,7 @@
<service id="sylius.context.cart.new_shop_based" class="Sylius\Component\Core\Cart\Context\ShopBasedCartContext" decorates="sylius.context.cart.new" decoration-priority="256" public="false">
<argument type="service" id="sylius.context.cart.new_shop_based.inner" />
<argument type="service" id="sylius.context.shopper" />
<argument type="service" id="Sylius\Component\Core\Cart\Resolver\CreatedByGuestFlagResolverInterface" />
<tag name="kernel.reset" method="reset" />
</service>
<service id="sylius.context.cart.customer_and_channel_based" class="Sylius\Bundle\CoreBundle\Context\CustomerAndChannelBasedCartContext">

View file

@ -76,6 +76,7 @@ final class ShopCartBlamerListener
}
$cart->setCustomer($user->getCustomer());
$cart->setByGuest(false);
}
/**

View file

@ -89,6 +89,7 @@ final class ShopCartBlamerListenerSpec extends ObjectBehavior
$user->getCustomer()->willReturn($customer);
$cart->setCustomer($customer)->shouldBeCalled();
$cart->setByGuest(false)->shouldBeCalled();
$this->onImplicitLogin($userEvent);
}
@ -110,6 +111,7 @@ final class ShopCartBlamerListenerSpec extends ObjectBehavior
$user->getCustomer()->willReturn($customer);
$cart->setCustomer($customer)->shouldBeCalled();
$cart->setByGuest(false)->shouldBeCalled();
$this->onInteractiveLogin(new InteractiveLoginEvent($request->getWrappedObject(), $token->getWrappedObject()));
}
@ -128,6 +130,7 @@ final class ShopCartBlamerListenerSpec extends ObjectBehavior
$cart->getCustomer()->willReturn($customer);
$cart->setCustomer(Argument::any())->shouldNotBeCalled();
$cart->setByGuest(false)->shouldNotBeCalled();
$this->onInteractiveLogin(new InteractiveLoginEvent($request->getWrappedObject(), $token->getWrappedObject()));
}
@ -145,6 +148,7 @@ final class ShopCartBlamerListenerSpec extends ObjectBehavior
$token->getUser()->willReturn('anon.');
$cart->setCustomer(Argument::any())->shouldNotBeCalled();
$cart->setByGuest(false)->shouldNotBeCalled();
$this->onInteractiveLogin(new InteractiveLoginEvent($request->getWrappedObject(), $token->getWrappedObject()));
}

View file

@ -14,6 +14,7 @@ declare(strict_types=1);
namespace Sylius\Component\Core\Cart\Context;
use Sylius\Component\Channel\Context\ChannelNotFoundException;
use Sylius\Component\Core\Cart\Resolver\CreatedByGuestFlagResolverInterface;
use Sylius\Component\Core\Context\ShopperContextInterface;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\CustomerInterface;
@ -31,12 +32,22 @@ final class ShopBasedCartContext implements CartContextInterface
private ShopperContextInterface $shopperContext;
private ?CreatedByGuestFlagResolverInterface $createdByGuestFlagResolver;
private ?OrderInterface $cart = null;
public function __construct(CartContextInterface $cartContext, ShopperContextInterface $shopperContext)
{
public function __construct(
CartContextInterface $cartContext,
ShopperContextInterface $shopperContext,
?CreatedByGuestFlagResolverInterface $createdByGuestFlagResolver = null
) {
$this->cartContext = $cartContext;
$this->shopperContext = $shopperContext;
$this->createdByGuestFlagResolver = $createdByGuestFlagResolver;
if ($createdByGuestFlagResolver === null) {
@trigger_error('Not passing createdByGuestFlagResolver through constructor is deprecated in Sylius 1.10.9 and it will be prohibited in Sylius 2.0');
}
}
public function getCart(): BaseOrderInterface
@ -72,10 +83,19 @@ final class ShopBasedCartContext implements CartContextInterface
return $cart;
}
public function reset(): void
{
$this->cart = null;
}
private function setCustomerAndAddressOnCart(OrderInterface $cart, CustomerInterface $customer): void
{
$cart->setCustomer($customer);
if ($this->createdByGuestFlagResolver !== null) {
$cart->setByGuest($this->createdByGuestFlagResolver->resolveFlag());
}
$defaultAddress = $customer->getDefaultAddress();
if (null !== $defaultAddress) {
$clonedAddress = clone $defaultAddress;
@ -83,9 +103,4 @@ final class ShopBasedCartContext implements CartContextInterface
$cart->setBillingAddress($clonedAddress);
}
}
public function reset(): void
{
$this->cart = null;
}
}

View file

@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\Component\Core\Cart\Resolver;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\User\UserInterface;
final class CreatedByGuestFlagResolver implements CreatedByGuestFlagResolverInterface
{
private TokenStorageInterface $tokenStorage;
public function __construct(TokenStorageInterface $tokenStorage)
{
$this->tokenStorage = $tokenStorage;
}
public function resolveFlag(): bool
{
$token = $this->tokenStorage->getToken();
if ($token === null) {
return true;
}
/** @var UserInterface|null $user */
$user = $token->getUser();
return null === $user;
}
}

View file

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* 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\Component\Core\Cart\Resolver;
interface CreatedByGuestFlagResolverInterface
{
public function resolveFlag(): bool;
}

View file

@ -110,6 +110,8 @@ class Order extends BaseOrder implements OrderInterface
*/
protected $customerIp;
protected bool $createdByGuest = true;
public function __construct()
{
parent::__construct();
@ -460,4 +462,14 @@ class Order extends BaseOrder implements OrderInterface
{
$this->customerIp = $customerIp;
}
public function getByGuest(): bool
{
return $this->createdByGuest;
}
public function setByGuest(bool $createdByGuest): void
{
$this->createdByGuest = $createdByGuest;
}
}

View file

@ -113,6 +113,10 @@ interface OrderInterface extends
public function setCustomerIp(?string $customerIp): void;
public function getByGuest(): bool;
public function setByGuest(bool $guest): void;
/**
* @return Collection|OrderItemInterface[]
*

View file

@ -16,6 +16,7 @@ namespace spec\Sylius\Component\Core\Cart\Context;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Component\Channel\Context\ChannelNotFoundException;
use Sylius\Component\Core\Cart\Resolver\CreatedByGuestFlagResolverInterface;
use Sylius\Component\Core\Context\ShopperContextInterface;
use Sylius\Component\Core\Model\AddressInterface;
use Sylius\Component\Core\Model\ChannelInterface;
@ -187,4 +188,70 @@ final class ShopBasedCartContextSpec extends ObjectBehavior
$this->reset();
$this->getCart()->shouldReturn($secondCart);
}
function it_sets_created_by_guest_flag_as_false_if_order_is_created_by_logged_in_customer(
CartContextInterface $cartContext,
ShopperContextInterface $shopperContext,
CreatedByGuestFlagResolverInterface $createdByGuestFlagResolver,
OrderInterface $cart,
ChannelInterface $channel,
CurrencyInterface $currency,
CustomerInterface $customer
): void {
$this->beConstructedWith($cartContext, $shopperContext, $createdByGuestFlagResolver);
$createdByGuestFlagResolver->resolveFlag()->willReturn(false);
$cart->setByGuest(false)->shouldBeCalled();
$cartContext->getCart()->shouldBeCalledTimes(1)->willReturn($cart);
$shopperContext->getChannel()->willReturn($channel);
$shopperContext->getLocaleCode()->willReturn('pl');
$shopperContext->getCustomer()->willReturn($customer);
$customer->getDefaultAddress()->willReturn(null);
$channel->getBaseCurrency()->willReturn($currency);
$currency->getCode()->willReturn('PLN');
$cart->setChannel($channel)->shouldBeCalled();
$cart->setCurrencyCode('PLN')->shouldBeCalled();
$cart->setLocaleCode('pl')->shouldBeCalled();
$cart->setCustomer($customer)->shouldBeCalled();
$this->getCart()->shouldReturn($cart);
}
function it_sets_created_by_guest_flag_as_true_if_order_is_created_by_anonymous_user(
CartContextInterface $cartContext,
ShopperContextInterface $shopperContext,
CreatedByGuestFlagResolverInterface $createdByGuestFlagResolver,
OrderInterface $cart,
ChannelInterface $channel,
CurrencyInterface $currency,
CustomerInterface $customer
): void {
$this->beConstructedWith($cartContext, $shopperContext, $createdByGuestFlagResolver);
$createdByGuestFlagResolver->resolveFlag()->willReturn(true);
$cart->setByGuest(true)->shouldBeCalled();
$cartContext->getCart()->shouldBeCalledTimes(1)->willReturn($cart);
$shopperContext->getChannel()->willReturn($channel);
$shopperContext->getLocaleCode()->willReturn('pl');
$shopperContext->getCustomer()->willReturn($customer);
$customer->getDefaultAddress()->willReturn(null);
$channel->getBaseCurrency()->willReturn($currency);
$currency->getCode()->willReturn('PLN');
$cart->setChannel($channel)->shouldBeCalled();
$cart->setCurrencyCode('PLN')->shouldBeCalled();
$cart->setLocaleCode('pl')->shouldBeCalled();
$cart->setCustomer($customer)->shouldBeCalled();
$this->getCart()->shouldReturn($cart);
}
}

View file

@ -0,0 +1,61 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Component\Core\Cart\Resolver;
use PhpSpec\ObjectBehavior;
use Sylius\Component\Core\Cart\Resolver\CreatedByGuestFlagResolverInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\User\UserInterface;
final class CreatedByGuestFlagResolverSpec extends ObjectBehavior
{
function let(TokenStorageInterface $tokenStorage): void
{
$this->beConstructedWith($tokenStorage);
}
function it_implements_a_created_by_guest_flag_resolver_interface(): void
{
$this->shouldImplement(CreatedByGuestFlagResolverInterface::class);
}
function it_returns_false_if_there_is_logged_in_customer(
TokenStorageInterface $tokenStorage,
TokenInterface $token,
UserInterface $user
): void {
$tokenStorage->getToken()->willReturn($token);
$token->getUser()->willReturn($user);
$this->resolveFlag()->shouldReturn(false);
}
function it_returns_true_if_order_is_created_by_anonymous_user(
TokenStorageInterface $tokenStorage,
TokenInterface $token
): void {
$tokenStorage->getToken()->willReturn($token);
$token->getUser()->willReturn(null);
$this->resolveFlag()->shouldReturn(true);
}
function it_returns_true_if_there_is_no_token(TokenStorageInterface $tokenStorage): void
{
$tokenStorage->getToken()->willReturn(null);
$this->resolveFlag()->shouldReturn(true);
}
}