diff --git a/doc/swagger.yml b/doc/swagger.yml index c41e14c62..676e5346f 100644 --- a/doc/swagger.yml +++ b/doc/swagger.yml @@ -543,6 +543,8 @@ paths: description: "Invalid input, validation failed." schema: $ref: "#/definitions/GeneralError" + 403: + description: "Not logged in or wrong email" /taxon-products-by-slug/{slug}: get: tags: diff --git a/spec/Handler/CompleteOrderHandlerSpec.php b/spec/Handler/CompleteOrderHandlerSpec.php index 844208517..45edd2570 100644 --- a/spec/Handler/CompleteOrderHandlerSpec.php +++ b/spec/Handler/CompleteOrderHandlerSpec.php @@ -5,32 +5,48 @@ namespace spec\Sylius\ShopApiPlugin\Handler; use PhpSpec\ObjectBehavior; +use Prophecy\Argument; use SM\Factory\FactoryInterface as StateMachineFactoryInterface; use SM\StateMachine\StateMachineInterface; use Sylius\Component\Core\Model\CustomerInterface; use Sylius\Component\Core\Model\OrderInterface; +use Sylius\Component\Core\Model\ShopUserInterface; use Sylius\Component\Core\OrderCheckoutTransitions; +use Sylius\Component\Core\Repository\CustomerRepositoryInterface; use Sylius\Component\Core\Repository\OrderRepositoryInterface; +use Sylius\Component\Resource\Factory\FactoryInterface; use Sylius\ShopApiPlugin\Command\CompleteOrder; -use Sylius\ShopApiPlugin\Provider\CustomerProviderInterface; +use Sylius\ShopApiPlugin\Exception\WrongUserException; +use Sylius\ShopApiPlugin\Provider\LoggedInUserProviderInterface; +use Symfony\Component\Security\Core\Exception\TokenNotFoundException; final class CompleteOrderHandlerSpec extends ObjectBehavior { - function let(OrderRepositoryInterface $orderRepository, CustomerProviderInterface $customerProvider, StateMachineFactoryInterface $stateMachineFactory): void - { - $this->beConstructedWith($orderRepository, $customerProvider, $stateMachineFactory); + function let( + OrderRepositoryInterface $orderRepository, + CustomerRepositoryInterface $customerRepository, + FactoryInterface $customerFactory, + LoggedInUserProviderInterface $loggedInUserProvider, + StateMachineFactoryInterface $stateMachineFactory + ): void { + $this->beConstructedWith($orderRepository, $customerRepository, $customerFactory, $loggedInUserProvider, $stateMachineFactory); } - function it_handles_order_completion_for_existing_customer( + function it_handles_order_completion_for_guest_checkout( CustomerInterface $customer, - CustomerProviderInterface $customerProvider, + CustomerRepositoryInterface $customerRepository, + LoggedInUserProviderInterface $loggedInUserProvider, + FactoryInterface $customerFactory, OrderInterface $order, OrderRepositoryInterface $orderRepository, StateMachineFactoryInterface $stateMachineFactory, StateMachineInterface $stateMachine ): void { $orderRepository->findOneBy(['tokenValue' => 'ORDERTOKEN'])->willReturn($order); - $customerProvider->provide('example@customer.com')->willReturn($customer); + + $customerRepository->findOneBy(['email' => 'example@customer.com'])->willReturn(null); + $customerFactory->createNew()->willReturn($customer); + $loggedInUserProvider->provide()->willThrow(TokenNotFoundException::class); $stateMachineFactory->get($order, OrderCheckoutTransitions::GRAPH)->willReturn($stateMachine); $stateMachine->can('complete')->willReturn(true); @@ -42,16 +58,74 @@ function it_handles_order_completion_for_existing_customer( $this->handle(new CompleteOrder('ORDERTOKEN', 'example@customer.com')); } + function it_throws_an_exception_if_the_email_address_has_already_a_customer( + CustomerInterface $customer, + CustomerRepositoryInterface $customerRepository, + LoggedInUserProviderInterface $loggedInUserProvider, + ShopUserInterface $shopUser, + OrderInterface $order, + OrderRepositoryInterface $orderRepository, + StateMachineFactoryInterface $stateMachineFactory, + StateMachineInterface $stateMachine + ): void { + $orderRepository->findOneBy(['tokenValue' => 'ORDERTOKEN'])->willReturn($order); + + $customerRepository->findOneBy(['email' => 'example@customer.com'])->willReturn($customer); + $shopUser->getCustomer()->willReturn($customer); + $loggedInUserProvider->provide()->willThrow(TokenNotFoundException::class); + + $stateMachineFactory->get($order, OrderCheckoutTransitions::GRAPH)->willReturn($stateMachine); + $stateMachine->can('complete')->willReturn(true); + + $order->setNotes(Argument::any())->shouldNotBeCalled(); + $order->setCustomer(Argument::any())->shouldNotBeCalled(); + $stateMachine->apply(Argument::any())->shouldNotBeCalled(); + + $this->shouldThrow(WrongUserException::class) + ->during('handle', [new CompleteOrder('ORDERTOKEN', 'example@customer.com')]); + } + + function it_handles_order_completetion( + CustomerRepositoryInterface $customerRepository, + LoggedInUserProviderInterface $loggedInUserProvider, + CustomerInterface $loggedInCustomer, + ShopUserInterface $shopUser, + OrderInterface $order, + OrderRepositoryInterface $orderRepository, + StateMachineFactoryInterface $stateMachineFactory, + StateMachineInterface $stateMachine + ): void { + $orderRepository->findOneBy(['tokenValue' => 'ORDERTOKEN'])->willReturn($order); + + $customerRepository->findOneBy(Argument::any())->shouldNotBeCalled(); + $shopUser->getCustomer()->willReturn($loggedInCustomer); + $loggedInUserProvider->provide()->willReturn($shopUser); + + $stateMachineFactory->get($order, OrderCheckoutTransitions::GRAPH)->willReturn($stateMachine); + $stateMachine->can('complete')->willReturn(true); + + $order->setCustomer($loggedInCustomer)->shouldBeCalled(); + $order->setNotes(null)->shouldBeCalled(); + $stateMachine->apply('complete')->shouldBeCalled(); + + $this->handle(new CompleteOrder('ORDERTOKEN', '')); + } + function it_handles_order_completion_with_notes( CustomerInterface $customer, - CustomerProviderInterface $customerProvider, + CustomerRepositoryInterface $customerRepository, + LoggedInUserProviderInterface $loggedInUserProvider, + ShopUserInterface $shopUser, OrderInterface $order, OrderRepositoryInterface $orderRepository, StateMachineFactoryInterface $stateMachineFactory, StateMachineInterface $stateMachine ): void { $orderRepository->findOneBy(['tokenValue' => 'ORDERTOKEN'])->willReturn($order); - $customerProvider->provide('example@customer.com')->willReturn($customer); + + $customerRepository->findOneBy(['email' => 'example@customer.com'])->willReturn($customer); + $shopUser->getCustomer()->willReturn($customer); + $loggedInUserProvider->provide()->willReturn($shopUser); $stateMachineFactory->get($order, OrderCheckoutTransitions::GRAPH)->willReturn($stateMachine); $stateMachine->can('complete')->willReturn(true); @@ -60,14 +134,46 @@ function it_handles_order_completion_with_notes( $order->setCustomer($customer)->shouldBeCalled(); $stateMachine->apply('complete')->shouldBeCalled(); - $this->handle(new CompleteOrder('ORDERTOKEN', 'example@customer.com', 'Some notes')); + $this->handle(new CompleteOrder('ORDERTOKEN', '', 'Some notes')); } - function it_throws_an_exception_if_order_does_not_exist(OrderRepositoryInterface $orderRepository): void - { + function it_throws_an_exception_if_order_does_not_exist( + OrderRepositoryInterface $orderRepository + ): void { $orderRepository->findOneBy(['tokenValue' => 'ORDERTOKEN'])->willReturn(null); - $this->shouldThrow(\InvalidArgumentException::class)->during('handle', [new CompleteOrder('ORDERTOKEN', 'example@customer.com')]); + $this->shouldThrow(\InvalidArgumentException::class) + ->during('handle', [new CompleteOrder('ORDERTOKEN', 'example@customer.com')]) + ; + } + + function it_throws_an_exception_if_the_user_is_logged_in_and_provides_email( + CustomerInterface $customer, + CustomerRepositoryInterface $customerRepository, + LoggedInUserProviderInterface $loggedInUserProvider, + CustomerInterface $loggedInCustomer, + ShopUserInterface $shopUser, + OrderInterface $order, + OrderRepositoryInterface $orderRepository, + StateMachineFactoryInterface $stateMachineFactory, + StateMachineInterface $stateMachine + ): void { + $orderRepository->findOneBy(['tokenValue' => 'ORDERTOKEN'])->willReturn($order); + + $customerRepository->findOneBy(Argument::any())->shouldNotBeCalled(); + $shopUser->getCustomer()->willReturn($loggedInCustomer); + $loggedInUserProvider->provide()->willReturn($shopUser); + + $stateMachineFactory->get($order, OrderCheckoutTransitions::GRAPH)->willReturn($stateMachine); + $stateMachine->can('complete')->willReturn(true); + + $order->setCustomer($customer)->shouldNotBeCalled(); + $order->setNotes('Some notes'); + $stateMachine->apply('complete')->shouldNotBeCalled(); + + $this->shouldThrow(\InvalidArgumentException::class) + ->during('handle', [new CompleteOrder('ORDERTOKEN', 'example@customer.com', 'Some notes')]) + ; } function it_throws_an_exception_if_order_cannot_be_addressed( diff --git a/src/Controller/Checkout/CompleteOrderAction.php b/src/Controller/Checkout/CompleteOrderAction.php index 70dcf89d9..a427f7e66 100644 --- a/src/Controller/Checkout/CompleteOrderAction.php +++ b/src/Controller/Checkout/CompleteOrderAction.php @@ -7,11 +7,12 @@ use FOS\RestBundle\View\View; use FOS\RestBundle\View\ViewHandlerInterface; use League\Tactician\CommandBus; -use Sylius\Component\Core\Model\ShopUserInterface; use Sylius\ShopApiPlugin\Command\CompleteOrder; +use Sylius\ShopApiPlugin\Exception\WrongUserException; +use Sylius\ShopApiPlugin\Provider\LoggedInUserProviderInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Exception\TokenNotFoundException; final class CompleteOrderAction { @@ -21,37 +22,42 @@ final class CompleteOrderAction /** @var CommandBus */ private $bus; - /** @var TokenStorageInterface */ - private $tokenStorage; + /** @var LoggedInUserProviderInterface */ + private $loggedInUserProvider; - public function __construct(ViewHandlerInterface $viewHandler, CommandBus $bus, TokenStorageInterface $tokenStorage) - { + public function __construct( + ViewHandlerInterface $viewHandler, + CommandBus $bus, + LoggedInUserProviderInterface $loggedInUserProvider + ) { $this->viewHandler = $viewHandler; $this->bus = $bus; - $this->tokenStorage = $tokenStorage; + $this->loggedInUserProvider = $loggedInUserProvider; } public function __invoke(Request $request): Response { - $email = $this->provideUserEmail($request); - - $this->bus->handle(new CompleteOrder( - $request->attributes->get('token'), - $email, - $request->request->get('notes') - )); - - return $this->viewHandler->handle(View::create(null, Response::HTTP_NO_CONTENT)); - } - - private function provideUserEmail(Request $request): string - { - $user = $this->tokenStorage->getToken()->getUser(); - - if ($user instanceof ShopUserInterface) { - return $user->getCustomer()->getEmail(); + try { + $this->bus->handle( + new CompleteOrder( + $request->attributes->get('token'), + $request->request->get('email', ''), + $request->request->get('notes') + ) + ); + } catch (WrongUserException $notLoggedInException) { + return $this->viewHandler->handle( + View::create( + 'You need to be logged in with the same user that wants to complete the order', + Response::HTTP_UNAUTHORIZED + ) + ); + } catch (TokenNotFoundException $notLoggedInException) { + return $this->viewHandler->handle( + View::create('You need to be logged in', Response::HTTP_UNAUTHORIZED) + ); } - return $request->request->get('email'); + return $this->viewHandler->handle(View::create(null, Response::HTTP_NO_CONTENT)); } } diff --git a/src/Exception/WrongUserException.php b/src/Exception/WrongUserException.php new file mode 100644 index 000000000..6522443ed --- /dev/null +++ b/src/Exception/WrongUserException.php @@ -0,0 +1,11 @@ +orderRepository = $orderRepository; - $this->customerProvider = $customerProvider; + $this->customerRepository = $customerRepository; $this->stateMachineFactory = $stateMachineFactory; + $this->customerFactory = $customerFactory; + $this->loggedInUserProvider = $loggedInUserProvider; } public function handle(CompleteOrder $completeOrder) @@ -44,11 +59,42 @@ public function handle(CompleteOrder $completeOrder) Assert::true($stateMachine->can(OrderCheckoutTransitions::TRANSITION_COMPLETE), sprintf('Order with %s token cannot be completed.', $completeOrder->orderToken())); - $customer = $this->customerProvider->provide($completeOrder->email()); - + $customer = $this->getCustomer($completeOrder->email()); $order->setNotes($completeOrder->notes()); $order->setCustomer($customer); $stateMachine->apply(OrderCheckoutTransitions::TRANSITION_COMPLETE); } + + private function getCustomer(string $emailAddress): CustomerInterface + { + try { + $loggedInUser = $this->loggedInUserProvider->provide(); + + if ($emailAddress !== '') { + throw new \InvalidArgumentException($emailAddress . ' has to be empty'); + + throw new \InvalidArgumentException('Can not have a logged in user and an email address'); + } + + /** @var CustomerInterface $customer */ + $customer = $loggedInUser->getCustomer(); + + return $customer; + } catch (TokenNotFoundException $notLoggedIn) { + /** @var CustomerInterface|null $customer */ + $customer = $this->customerRepository->findOneBy(['email' => $emailAddress]); + + // If the customer does not exist then it's normal checkout + if ($customer === null) { + /** @var CustomerInterface $customer */ + $customer = $this->customerFactory->createNew(); + $customer->setEmail($emailAddress); + + return $customer; + } + + throw new WrongUserException('Email is already taken'); + } + } } diff --git a/src/Provider/LoggedInUserProvider.php b/src/Provider/LoggedInUserProvider.php index 1493271ea..6547879f2 100644 --- a/src/Provider/LoggedInUserProvider.php +++ b/src/Provider/LoggedInUserProvider.php @@ -20,11 +20,16 @@ public function __construct(TokenStorageInterface $tokenStorage) public function provide(): ShopUserInterface { - /** @var ShopUserInterface $user */ - $user = $this->tokenStorage->getToken()->getUser(); + $token = $this->tokenStorage->getToken(); + if ($token === null) { + throw new TokenNotFoundException('No token found'); + } + + /** @var ShopUserInterface|null $user */ + $user = $token->getUser(); if (!$user instanceof ShopUserInterface) { - throw new TokenNotFoundException(); + throw new TokenNotFoundException('No logged in user'); } return $user; diff --git a/src/Resources/config/services/actions/checkout.xml b/src/Resources/config/services/actions/checkout.xml index b94d9bfc7..e1d31ef66 100644 --- a/src/Resources/config/services/actions/checkout.xml +++ b/src/Resources/config/services/actions/checkout.xml @@ -43,7 +43,7 @@ > - + diff --git a/src/Resources/config/services/handler/cart.xml b/src/Resources/config/services/handler/cart.xml index f98d12942..c863a0190 100644 --- a/src/Resources/config/services/handler/cart.xml +++ b/src/Resources/config/services/handler/cart.xml @@ -101,7 +101,9 @@ - + + + diff --git a/tests/Controller/CheckoutCompleteOrderApiTest.php b/tests/Controller/CheckoutCompleteOrderApiTest.php index 49e8ca990..1687d6145 100644 --- a/tests/Controller/CheckoutCompleteOrderApiTest.php +++ b/tests/Controller/CheckoutCompleteOrderApiTest.php @@ -12,9 +12,12 @@ use Sylius\ShopApiPlugin\Command\PutSimpleItemToCart; use Sylius\ShopApiPlugin\Model\Address; use Symfony\Component\HttpFoundation\Response; +use Tests\Sylius\ShopApiPlugin\Controller\Utils\ShopUserLoginTrait; final class CheckoutCompleteOrderApiTest extends JsonApiTestCase { + use ShopUserLoginTrait; + /** * @test */ @@ -154,19 +157,8 @@ public function it_allows_to_complete_checkout_without_email_for_logged_in_custo $bus->handle(new ChooseShippingMethod($token, 0, 'DHL')); $bus->handle(new ChoosePaymentMethod($token, 0, 'PBC')); - $data = -<<client->request('POST', '/shop-api/login_check', [], [], ['CONTENT_TYPE' => 'application/json', 'ACCEPT' => 'application/json'], $data); - - $response = json_decode($this->client->getResponse()->getContent(), true); + $this->logInUser('oliver@queen.com', '123password'); - $this->client->setServerParameter('HTTP_Authorization', sprintf('Bearer %s', $response['token'])); $this->client->request('PUT', sprintf('/shop-api/WEB_GB/checkout/%s/complete', $token), [], [], [ 'CONTENT_TYPE' => 'application/json', 'ACCEPT' => 'application/json', @@ -181,7 +173,7 @@ public function it_allows_to_complete_checkout_without_email_for_logged_in_custo */ public function it_does_not_allow_to_complete_order_in_non_existent_channel() { - $this->loadFixturesFromFiles(['shop.yml', 'country.yml', 'shipping.yml', 'payment.yml']); + $this->loadFixturesFromFiles(['shop.yml', 'country.yml', 'shipping.yml', 'payment.yml', 'customer.yml']); $token = 'SDAOSLEFNWU35H3QLI5325'; @@ -226,4 +218,54 @@ public function it_does_not_allow_to_complete_order_in_non_existent_channel() $response = $this->client->getResponse(); $this->assertResponse($response, 'channel_has_not_been_found_response', Response::HTTP_NOT_FOUND); } + + /** + * @test + */ + public function it_disallows_users_to_complete_checkout_for_someone_else() + { + $this->loadFixturesFromFiles(['shop.yml', 'country.yml', 'shipping.yml', 'payment.yml', 'customer.yml']); + + $token = 'SDAOSLEFNWU35H3QLI5325'; + + /** @var CommandBus $bus */ + $bus = $this->get('tactician.commandbus'); + $bus->handle(new PickupCart($token, 'WEB_GB')); + $bus->handle(new PutSimpleItemToCart($token, 'LOGAN_MUG_CODE', 5)); + $bus->handle(new AddressOrder( + $token, + Address::createFromArray([ + 'firstName' => 'Sherlock', + 'lastName' => 'Holmes', + 'city' => 'London', + 'street' => 'Baker Street 221b', + 'countryCode' => 'GB', + 'postcode' => 'NWB', + 'provinceName' => 'Greater London', + ]), Address::createFromArray([ + 'firstName' => 'Sherlock', + 'lastName' => 'Holmes', + 'city' => 'London', + 'street' => 'Baker Street 221b', + 'countryCode' => 'GB', + 'postcode' => 'NWB', + 'provinceName' => 'Greater London', + ]) + )); + $bus->handle(new ChooseShippingMethod($token, 0, 'DHL')); + $bus->handle(new ChoosePaymentMethod($token, 0, 'PBC')); + + $data = <<client->request('PUT', sprintf('/shop-api/WEB_GB/checkout/%s/complete', $token), [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'ACCEPT' => 'application/json', + ], $data); + $response = $this->client->getResponse(); + $this->assertResponseCode($response, Response::HTTP_UNAUTHORIZED); + } }