diff --git a/README.md b/README.md index f03fac776..0f07766b7 100644 --- a/README.md +++ b/README.md @@ -79,18 +79,39 @@ The latest documentation is available [here](https://app.swaggerhub.com/apis/Syl # ... sylius.security.shop_regex: "^/(?!admin|api/.*|api$|shop-api|media/.*)[^/]++" # shop-api has been added inside the brackets - shop_api.security.regex: "^/shop-api" + sylius_shop_api.security.regex: "^/shop-api" # ... security: firewalls: // ... + + sylius_shop_api_login: + pattern: "%sylius_shop_api.security.regex%/login" + stateless: true + anonymous: true + form_login: + provider: sylius_shop_user_provider + login_path: /shop-api/login_check + check_path: /shop-api/login_check + success_handler: lexik_jwt_authentication.handler.authentication_success + failure_handler: lexik_jwt_authentication.handler.authentication_failure + require_previous_session: false - shop_api: - pattern: "%shop_api.security.regex%" - stateless: true - anonymous: true + sylius_shop_api: + pattern: "%sylius_shop_api.security.regex%" + stateless: true + anonymous: true + guard: + provider: sylius_shop_user_provider + authenticators: + - lexik_jwt_authentication.jwt_token_authenticator + + access_control: + - { path: "%sylius_shop_api.security.regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "%sylius_shop_api.security.regex%/register", role: IS_AUTHENTICATED_ANONYMOUSLY } + ``` 6. (optional) if you have installed `nelmio/NelmioCorsBundle` for Support of Cross-Origin Ajax Request, @@ -142,16 +163,8 @@ sylius_shop_api: - "MUG_MATERIAL_CODE" ``` -### Authorization - -By default no authorization is provided together with this bundle. But it is tested to work along with [LexikJWTAuthenticationBundle](https://github.com/lexik/LexikJWTAuthenticationBundle) -In order to check example configuration check - - [security.yml](https://github.com/Sylius/SyliusShopApiPlugin/blob/master/tests/Application/app/config/security.yml) - - [jwt parameters](https://github.com/Sylius/SyliusShopApiPlugin/blob/master/tests/Application/app/config/config.yml#L4-L7) and [jwt config](https://github.com/Sylius/SyliusShopApiPlugin/blob/master/tests/Application/app/config/config.yml#L55-L59) in config.yml - - [example rsa keys](https://github.com/Sylius/SyliusShopApiPlugin/tree/master/tests/Application/app/config/jwt) - - [login request](https://github.com/Sylius/SyliusShopApiPlugin/blob/master/tests/Controller/CustomerShopApiTest.php#L52-L68) - -From the test app. +This plugin comes with an integration with [LexikJWTAuthenticationBundle](https://github.com/lexik/LexikJWTAuthenticationBundle/). +More information about security customizations may be found there. ## Testing diff --git a/composer.json b/composer.json index b608b2cdd..7b3315e5f 100644 --- a/composer.json +++ b/composer.json @@ -7,11 +7,11 @@ "php": "^7.2", "sylius/sylius": "^1.4", + "lexik/jwt-authentication-bundle": "^2.5", "symfony/messenger": "~4.3.0" }, "require-dev": { "lchrusciel/api-test-case": "^4.0", - "lexik/jwt-authentication-bundle": "^2.5", "matthiasnoback/symfony-config-test": "^4.0", "matthiasnoback/symfony-dependency-injection-test": "^4.0", "phpspec/phpspec": "^5.0", diff --git a/doc/swagger.yml b/doc/swagger.yml index acdd0203f..72cee09c3 100644 --- a/doc/swagger.yml +++ b/doc/swagger.yml @@ -764,6 +764,25 @@ paths: description: "There were validation errors" 500: description: "Channel not found" + + /login_check: + post: + tags: + - "users" + summary: "Log user in" + description: "This action allows to log existing user in" + operationId: "loginUser" + parameters: + - name: "content" + in: "body" + required: true + schema: + $ref: "#/definitions/LoginRequest" + responses: + 204: + description: "The user was successfully created" + 400: + description: "There were validation errors" /orders: parameters: - $ref: "#/parameters/ChannelCode" @@ -1569,6 +1588,15 @@ definitions: channel: type: "string" example: "WEB_GB" + LoginRequest: + type: "object" + properties: + _username: + type: "string" + example: "test@example.com" + _password: + type: "string" + example: "test12334verysecure" UpdateUserRequest: type: "object" properties: diff --git a/src/EventListener/CartBlamerListener.php b/src/EventListener/CartBlamerListener.php new file mode 100644 index 000000000..59489831e --- /dev/null +++ b/src/EventListener/CartBlamerListener.php @@ -0,0 +1,91 @@ +cartManager = $cartManager; + $this->cartContext = $cartContext; + $this->cartRepository = $cartRepository; + $this->requestStack = $requestStack; + } + + public function onJwtLogin(JWTCreatedEvent $interactiveLoginEvent): void + { + $user = $interactiveLoginEvent->getUser(); + $request = $this->requestStack->getCurrentRequest(); + + Assert::notNull($request); + + if (!$user instanceof ShopUserInterface) { + return; + } + + $cart = $this->getCart($request->request->get('token')); + + if (null === $cart) { + return; + } + + $cart->setCustomer($user->getCustomer()); + $this->cartManager->persist($cart); + $this->cartManager->flush(); + } + + private function getCart(?string $token): ?OrderInterface + { + if (null !== $token) { + /** @var OrderInterface $cart */ + $cart = $this->cartRepository->findOneBy(['tokenValue' => $token]); + + return $cart; + } + + try { + /** @var OrderInterface $cart */ + $cart = $this->cartContext->getCart(); + + return $cart; + } catch (CartNotFoundException $exception) { + return null; + } + } +} diff --git a/src/EventListener/UserCartRecalculationListener.php b/src/EventListener/UserCartRecalculationListener.php new file mode 100644 index 000000000..acf8dead9 --- /dev/null +++ b/src/EventListener/UserCartRecalculationListener.php @@ -0,0 +1,49 @@ +cartContext = $cartContext; + $this->orderProcessor = $orderProcessor; + $this->cartManager = $cartManager; + } + + public function recalculateCartWhileLogin(): void + { + try { + $cart = $this->cartContext->getCart(); + } catch (CartNotFoundException $exception) { + return; + } + + Assert::isInstanceOf($cart, OrderInterface::class); + + $this->orderProcessor->process($cart); + + $this->cartManager->flush(); + } +} diff --git a/src/Resources/config/routing.yml b/src/Resources/config/routing.yml index 64c97eee0..5b134d5c7 100644 --- a/src/Resources/config/routing.yml +++ b/src/Resources/config/routing.yml @@ -37,3 +37,7 @@ sylius_shop_api_address_book: sylius_shop_api_order: resource: "@ShopApiPlugin/Resources/config/routing/order.yml" prefix: /shop-api + +sylius_shop_api_login_check: + methods: [POST] + path: /shop-api/login_check diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index 43953a561..828794a13 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -72,6 +72,24 @@ + + + + + + + + + + + + + + + diff --git a/tests/Application/config/packages/security.yaml b/tests/Application/config/packages/security.yaml index ac16053ca..93739f1f8 100644 --- a/tests/Application/config/packages/security.yaml +++ b/tests/Application/config/packages/security.yaml @@ -2,7 +2,7 @@ parameters: sylius.security.admin_regex: "^/admin" sylius.security.api_regex: "^/api" sylius.security.shop_regex: "^/(?!admin|api/.*|api$|media/.*)[^/]++" - shop_api.security.regex: "^/shop-api" + sylius_shop_api.security.regex: "^/shop-api" security: providers: @@ -52,7 +52,7 @@ security: anonymous: true sylius_shop_api_login: - pattern: "%shop_api.security.regex%/login" + pattern: "%sylius_shop_api.security.regex%/login" stateless: true anonymous: true form_login: @@ -63,8 +63,8 @@ security: failure_handler: lexik_jwt_authentication.handler.authentication_failure require_previous_session: false - shop_api: - pattern: "%shop_api.security.regex%" + sylius_shop_api: + pattern: "%sylius_shop_api.security.regex%" stateless: true anonymous: true guard: @@ -115,11 +115,11 @@ security: - { path: "%sylius.security.admin_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.api_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.shop_regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%shop_api.security.regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "%sylius_shop_api.security.regex%/login", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.shop_regex%/register", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.shop_regex%/verify", role: IS_AUTHENTICATED_ANONYMOUSLY } - - { path: "%shop_api.security.regex%/register", role: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: "%sylius_shop_api.security.regex%/register", role: IS_AUTHENTICATED_ANONYMOUSLY } - { path: "%sylius.security.admin_regex%", role: ROLE_ADMINISTRATION_ACCESS } - { path: "%sylius.security.api_regex%/.*", role: ROLE_API_ACCESS } diff --git a/tests/Application/config/routes.yaml b/tests/Application/config/routes.yaml index 9645b82d5..05107a37b 100644 --- a/tests/Application/config/routes.yaml +++ b/tests/Application/config/routes.yaml @@ -5,7 +5,3 @@ sylius: sylius_shop_api: resource: "@ShopApiPlugin/Resources/config/routing.yml" - -sylius_shop_api_login_check: - methods: [POST] - path: /shop-api/login_check diff --git a/tests/Controller/Cart/CartPickupApiTest.php b/tests/Controller/Cart/CartPickupApiTest.php index ec9730e0a..c6fe27580 100644 --- a/tests/Controller/Cart/CartPickupApiTest.php +++ b/tests/Controller/Cart/CartPickupApiTest.php @@ -4,8 +4,11 @@ namespace Tests\Sylius\ShopApiPlugin\Controller\Cart; +use Sylius\Component\Core\Model\OrderInterface; use Sylius\Component\Core\Repository\OrderRepositoryInterface; +use Sylius\ShopApiPlugin\Command\Cart\PickupCart; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\MessageBusInterface; use Tests\Sylius\ShopApiPlugin\Controller\JsonApiTestCase; use Tests\Sylius\ShopApiPlugin\Controller\Utils\ShopUserLoginTrait; @@ -51,4 +54,32 @@ public function it_only_creates_one_cart_if_user_is_logged_in(): void $this->assertCount(1, $orders, 'Only one cart should be created'); } + + /** + * @test + */ + public function it_does_not_create_a_new_cart_if_cart_was_picked_up_before_logging_in(): void + { + $this->loadFixturesFromFiles(['shop.yml', 'customer.yml']); + + $token = 'SDAOSLEFNWU35H3QLI5325'; + + /** @var MessageBusInterface $bus */ + $bus = $this->get('sylius_shop_api_plugin.command_bus'); + $bus->dispatch(new PickupCart($token, 'WEB_GB')); + + $this->logInUserWithCart('oliver@queen.com', '123password', $token); + + /** @var OrderRepositoryInterface $orderRepository */ + $orderRepository = $this->get('sylius.repository.order'); + $orders = $orderRepository->findAll(); + + $this->assertCount(1, $orders, 'Only one cart should be created'); + + /** @var OrderInterface $order */ + $order = $orders[0]; + $customer = $order->getCustomer(); + $this->assertNotNull($customer, 'Cart should have customer assigned, but it has not.'); + $this->assertSame('oliver@queen.com', $customer->getEmail()); + } } diff --git a/tests/Controller/Cart/CartPutItemToCartApiTest.php b/tests/Controller/Cart/CartPutItemToCartApiTest.php index a3c6ce4df..735310e72 100644 --- a/tests/Controller/Cart/CartPutItemToCartApiTest.php +++ b/tests/Controller/Cart/CartPutItemToCartApiTest.php @@ -15,9 +15,12 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; use Tests\Sylius\ShopApiPlugin\Controller\JsonApiTestCase; +use Tests\Sylius\ShopApiPlugin\Controller\Utils\ShopUserLoginTrait; final class CartPutItemToCartApiTest extends JsonApiTestCase { + use ShopUserLoginTrait; + /** * @test */ @@ -44,6 +47,28 @@ public function it_adds_a_product_to_the_cart(): void $this->assertResponse($response, 'cart/add_simple_product_to_cart_response', Response::HTTP_CREATED); } + /** + * @test + */ + public function it_recalculates_cart_when_customer_log_in(): void + { + $this->loadFixturesFromFiles(['shop.yml', 'customer.yml', 'promotion.yml']); + + $token = 'SDAOSLEFNWU35H3QLI5325'; + + /** @var MessageBusInterface $bus */ + $bus = $this->get('sylius_shop_api_plugin.command_bus'); + $bus->dispatch(new PickupCart($token, 'WEB_GB')); + $bus->dispatch(new PutSimpleItemToCart($token, 'LOGAN_MUG_CODE', 1)); + + $this->logInUserWithCart('oliver@queen.com', '123password', $token); + + $this->client->request('GET', '/shop-api/carts/' . $token, [], [], self::CONTENT_TYPE_HEADER); + $response = $this->client->getResponse(); + + $this->assertResponse($response, 'cart/recalculated_cart_after_log_in', Response::HTTP_OK); + } + /** * @test */ diff --git a/tests/Controller/Utils/ShopUserLoginTrait.php b/tests/Controller/Utils/ShopUserLoginTrait.php index 5a846a513..81817cafc 100644 --- a/tests/Controller/Utils/ShopUserLoginTrait.php +++ b/tests/Controller/Utils/ShopUserLoginTrait.php @@ -23,8 +23,29 @@ protected function logInUser(string $username, string $password): void } EOT; + $this->sendLogInRequest($data); + } + + protected function logInUserWithCart(string $username, string $password, string $token): void + { + $data = +<<sendLogInRequest($data); + } + + private function sendLogInRequest(string $data): void + { $this->client->request('POST', '/shop-api/login_check', [], [], ['CONTENT_TYPE' => 'application/json', 'ACCEPT' => 'application/json'], $data); + Assert::assertSame($this->client->getResponse()->getStatusCode(), Response::HTTP_OK); + $response = json_decode($this->client->getResponse()->getContent(), true); $this->client->setServerParameter('HTTP_Authorization', sprintf('Bearer %s', $response['token'])); } diff --git a/tests/DataFixtures/ORM/customer.yml b/tests/DataFixtures/ORM/customer.yml index ffce3fa78..f6aca3428 100644 --- a/tests/DataFixtures/ORM/customer.yml +++ b/tests/DataFixtures/ORM/customer.yml @@ -22,6 +22,7 @@ Sylius\Component\Core\Model\Customer: email: "oliver@queen.com" emailCanonical: "oliver@queen.com" gender: "m" + group: "@retail" phoneNumber: "0212115512" hater: @@ -29,3 +30,8 @@ Sylius\Component\Core\Model\Customer: lastName: "Wilson" email: "hater@queen.com" emailCanonical: "slade@queen.com" + +Sylius\Component\Customer\Model\CustomerGroup: + retail: + name: "Retail" + code: "retail" diff --git a/tests/DataFixtures/ORM/promotion.yml b/tests/DataFixtures/ORM/promotion.yml index 94d1846ec..426f6a90b 100644 --- a/tests/DataFixtures/ORM/promotion.yml +++ b/tests/DataFixtures/ORM/promotion.yml @@ -6,12 +6,24 @@ Sylius\Component\Core\Model\Promotion: actions: ["@10_percent_order_discount"] rules: ["@over_50_amount_rule"] + customer_promotion: + code: "CUSTOMER_PROMOTION" + name: "Holiday promotion" + channels: ["@gb_web_channel", "@de_web_channel"] + actions: ["@20_percent_order_discount"] + rules: ["@customer_group_rule"] + Sylius\Component\Promotion\Model\PromotionAction: 10_percent_order_discount: type: "order_percentage_discount" promotion: "@holiday_promotion" configuration: percentage: 0.1 + 20_percent_order_discount: + type: "order_percentage_discount" + promotion: "@customer_promotion" + configuration: + percentage: 0.2 Sylius\Component\Promotion\Model\PromotionRule: over_50_amount_rule: @@ -22,3 +34,8 @@ Sylius\Component\Promotion\Model\PromotionRule: amount: 5000 WEB_DE: amount: 5000 + customer_group_rule: + type: "customer_group" + promotion: "@customer_promotion" + configuration: + group_code: 'retail' diff --git a/tests/Responses/Expected/cart/recalculated_cart_after_log_in.json b/tests/Responses/Expected/cart/recalculated_cart_after_log_in.json new file mode 100644 index 000000000..2df518c57 --- /dev/null +++ b/tests/Responses/Expected/cart/recalculated_cart_after_log_in.json @@ -0,0 +1,79 @@ +{ + "tokenValue": "SDAOSLEFNWU35H3QLI5325", + "channel": "WEB_GB", + "currency": "GBP", + "locale": "en_GB", + "checkoutState": "cart", + "items": [ + { + "id": @integer@, + "quantity": 1, + "total": 1599, + "product": { + "code": "LOGAN_MUG_CODE", + "name": "Logan Mug", + "slug": "logan-mug", + "channelCode": "WEB_GB", + "description": "Some description Lorem ipsum dolor sit amet.", + "averageRating": 0, + "taxons": { + "main": "MUG", + "others": [ + "MUG", + "BRAND" + ] + }, + "variants": [ + { + "code": "LOGAN_MUG_CODE", + "name": "Logan Mug", + "axis": [], + "nameAxis": {}, + "price": { + "current": 1999, + "currency": "GBP" + }, + "images": [] + } + ], + "attributes": [ + { + "code": "MUG_MATERIAL_CODE", + "name": "Mug material", + "value": "Wood" + } + ], + "associations": [], + "images": [ + { + "code": "thumbnail", + "path": "\/uo\/mug.jpg" + } + ], + "_links": { + "self": { + "href": "\/shop-api\/products\/by-slug\/logan-mug" + } + } + } + } + ], + "totals": { + "total": 1599, + "items": 1599, + "taxes": 0, + "shipping": 0, + "promotion": -400 + }, + "payments": [], + "shipments": [], + "cartDiscounts": { + "CUSTOMER_PROMOTION": { + "name": "Holiday promotion", + "amount": { + "current": -400, + "currency": "GBP" + } + } + } +}