diff --git a/UPGRADE-API-2.0.md b/UPGRADE-API-2.0.md index 6641f0fff0..564cb749d9 100644 --- a/UPGRADE-API-2.0.md +++ b/UPGRADE-API-2.0.md @@ -1,3 +1,22 @@ +# UPGRADE FROM `2.0.17` TO `2.0.18` + +## Security + +### Payment request ownership enforcement + +`GET /api/v2/shop/payment-requests/{hash}` and `PUT /api/v2/shop/payment-requests/{hash}` now enforce ownership: + +- An unauthenticated request receives `404 Not Found`, unless the underlying order is a guest order (its customer has no associated user account). +- An authenticated shop user can only access payment requests belonging to their own orders; requests for another customer's payment request also receive `404 Not Found`. + +Previously both operations could be performed by any authenticated user or even an anonymous user who knew the payment request hash, which constituted a broken object-level authorization (IDOR) vulnerability. + +**No action is required** unless your integration assumed that these endpoints were publicly accessible or shared across customers. + +### `Sylius\Bundle\ApiBundle\StateProvider\Shop\Payment\PaymentRequest\ItemProvider` constructor + +A new `UserContextInterface` argument is appended to the constructor. Omitting it is deprecated and will be required in Sylius 3.0; without it the ownership check is skipped and the provider falls back to its previous behavior. + # UPGRADE FROM `1.14` TO `2.0` ## Codebase diff --git a/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/ShopUserBasedExtension.php b/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/ShopUserBasedExtension.php new file mode 100644 index 0000000000..fdd04cdef7 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/ShopUserBasedExtension.php @@ -0,0 +1,93 @@ + $context + */ + public function applyToCollection( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + ?Operation $operation = null, + array $context = [], + ): void { + $this->filterByCustomer($queryBuilder, $queryNameGenerator, $resourceClass); + } + + /** + * @param array $identifiers + * @param array $context + */ + public function applyToItem( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + array $identifiers, + ?Operation $operation = null, + array $context = [], + ): void { + $this->filterByCustomer($queryBuilder, $queryNameGenerator, $resourceClass); + } + + private function filterByCustomer( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + ): void { + if (!is_a($resourceClass, PaymentRequestInterface::class, true)) { + return; + } + + if (!$this->sectionProvider->getSection() instanceof ShopApiSection) { + return; + } + + $user = $this->userContext->getUser(); + if (!$user instanceof ShopUserInterface) { + return; + } + + $rootAlias = $queryBuilder->getRootAliases()[0]; + $paymentJoinAlias = $queryNameGenerator->generateJoinAlias('payment'); + $orderJoinAlias = $queryNameGenerator->generateJoinAlias('order'); + $customerParameterName = $queryNameGenerator->generateParameterName('customer'); + + $queryBuilder + ->innerJoin(sprintf('%s.payment', $rootAlias), $paymentJoinAlias) + ->innerJoin(sprintf('%s.order', $paymentJoinAlias), $orderJoinAlias) + ->andWhere($queryBuilder->expr()->eq(sprintf('%s.customer', $orderJoinAlias), sprintf(':%s', $customerParameterName))) + ->setParameter($customerParameterName, $user->getCustomer()) + ; + } +} diff --git a/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/VisitorBasedExtension.php b/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/VisitorBasedExtension.php new file mode 100644 index 0000000000..89e4f7b146 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/VisitorBasedExtension.php @@ -0,0 +1,76 @@ + $identifiers + * @param array $context + */ + public function applyToItem( + QueryBuilder $queryBuilder, + QueryNameGeneratorInterface $queryNameGenerator, + string $resourceClass, + array $identifiers, + ?Operation $operation = null, + array $context = [], + ): void { + if (!is_a($resourceClass, PaymentRequestInterface::class, true)) { + return; + } + + if (!$this->sectionProvider->getSection() instanceof ShopApiSection) { + return; + } + + if (null !== $this->userContext->getUser()) { + return; + } + + $rootAlias = $queryBuilder->getRootAliases()[0]; + $paymentJoinAlias = $queryNameGenerator->generateJoinAlias('payment'); + $orderJoinAlias = $queryNameGenerator->generateJoinAlias('order'); + $customerJoinAlias = $queryNameGenerator->generateJoinAlias('customer'); + $userJoinAlias = $queryNameGenerator->generateJoinAlias('user'); + + $queryBuilder + ->innerJoin(sprintf('%s.payment', $rootAlias), $paymentJoinAlias) + ->innerJoin(sprintf('%s.order', $paymentJoinAlias), $orderJoinAlias) + ->leftJoin(sprintf('%s.customer', $orderJoinAlias), $customerJoinAlias) + ->leftJoin(sprintf('%s.user', $customerJoinAlias), $userJoinAlias) + ->andWhere( + $queryBuilder->expr()->orX( + $queryBuilder->expr()->isNull($customerJoinAlias), + $queryBuilder->expr()->isNull($userJoinAlias), + ), + ) + ; + } +} diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/extensions.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/extensions.xml index 9225d609f6..829c988a0b 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/extensions.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/extensions.xml @@ -280,5 +280,24 @@ + + + + + + + + + + + + + diff --git a/src/Sylius/Bundle/ApiBundle/Resources/config/services/shop/state_providers.xml b/src/Sylius/Bundle/ApiBundle/Resources/config/services/shop/state_providers.xml index 4a7b7fe95d..c716f6ab33 100644 --- a/src/Sylius/Bundle/ApiBundle/Resources/config/services/shop/state_providers.xml +++ b/src/Sylius/Bundle/ApiBundle/Resources/config/services/shop/state_providers.xml @@ -76,6 +76,7 @@ + diff --git a/src/Sylius/Bundle/ApiBundle/StateProvider/Shop/Payment/PaymentRequest/ItemProvider.php b/src/Sylius/Bundle/ApiBundle/StateProvider/Shop/Payment/PaymentRequest/ItemProvider.php index a7334e20ae..42254e5685 100644 --- a/src/Sylius/Bundle/ApiBundle/StateProvider/Shop/Payment/PaymentRequest/ItemProvider.php +++ b/src/Sylius/Bundle/ApiBundle/StateProvider/Shop/Payment/PaymentRequest/ItemProvider.php @@ -16,9 +16,14 @@ namespace Sylius\Bundle\ApiBundle\StateProvider\Shop\Payment\PaymentRequest; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Put; use ApiPlatform\State\ProviderInterface; +use Sylius\Bundle\ApiBundle\Context\UserContextInterface; use Sylius\Bundle\ApiBundle\SectionResolver\ShopApiSection; use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface; use Sylius\Bundle\PaymentBundle\Checker\FinalizedPaymentRequestCheckerInterface; +use Sylius\Component\Core\Model\CustomerInterface; +use Sylius\Component\Core\Model\OrderInterface; +use Sylius\Component\Core\Model\PaymentInterface; +use Sylius\Component\Core\Model\ShopUserInterface; use Sylius\Component\Payment\Model\PaymentRequestInterface; use Sylius\Component\Payment\Repository\PaymentRequestRepositoryInterface; use Webmozart\Assert\Assert; @@ -35,7 +40,17 @@ final readonly class ItemProvider implements ProviderInterface private SectionProviderInterface $sectionProvider, private PaymentRequestRepositoryInterface $paymentRequestRepository, private FinalizedPaymentRequestCheckerInterface $finalizedPaymentRequestChecker, + private ?UserContextInterface $userContext = null, ) { + if ($userContext === null) { + trigger_deprecation( + 'sylius/api-bundle', + '2.2', + 'Not passing a "%s" to the "%s" constructor is deprecated and will be required in Sylius 3.0.', + UserContextInterface::class, + self::class, + ); + } } public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object|null @@ -45,14 +60,60 @@ final readonly class ItemProvider implements ProviderInterface Assert::isInstanceOf($this->sectionProvider->getSection(), ShopApiSection::class); $paymentRequest = $this->paymentRequestRepository->find($uriVariables['hash']); + if (null === $paymentRequest) { + return null; + } - if ( - $paymentRequest === null || - $this->finalizedPaymentRequestChecker->isFinal($paymentRequest) - ) { + if ($this->userContext !== null && !$this->isAccessibleByCurrentUser($paymentRequest)) { + return null; + } + + if ($this->finalizedPaymentRequestChecker->isFinal($paymentRequest)) { return null; } return $paymentRequest; } + + private function isAccessibleByCurrentUser(PaymentRequestInterface $paymentRequest): bool + { + $user = $this->userContext->getUser(); + + if ($user instanceof ShopUserInterface) { + $customer = $user->getCustomer(); + + return $customer instanceof CustomerInterface && $this->isOwnedByCustomer($paymentRequest, $customer); + } + + return $this->isGuestOrder($paymentRequest); + } + + private function isOwnedByCustomer(PaymentRequestInterface $paymentRequest, CustomerInterface $customer): bool + { + $payment = $paymentRequest->getPayment(); + if (!$payment instanceof PaymentInterface) { + return false; + } + + $order = $payment->getOrder(); + + return $order instanceof OrderInterface && $order->getCustomer() === $customer; + } + + private function isGuestOrder(PaymentRequestInterface $paymentRequest): bool + { + $payment = $paymentRequest->getPayment(); + if (!$payment instanceof PaymentInterface) { + return false; + } + + $order = $payment->getOrder(); + if (!$order instanceof OrderInterface) { + return false; + } + + $customer = $order->getCustomer(); + + return null === $customer || null === $customer->getUser(); + } } diff --git a/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/ShopUserBasedExtensionTest.php b/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/ShopUserBasedExtensionTest.php new file mode 100644 index 0000000000..ba3c3aa388 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/ShopUserBasedExtensionTest.php @@ -0,0 +1,246 @@ +sectionProvider = $this->createMock(SectionProviderInterface::class); + $this->userContext = $this->createMock(UserContextInterface::class); + $this->extension = new ShopUserBasedExtension($this->sectionProvider, $this->userContext); + } + + public function test_does_not_apply_conditions_to_collection_for_unsupported_resource(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + + $this->userContext->expects($this->never())->method('getUser'); + $queryBuilder->expects($this->never())->method('andWhere'); + + $this->extension->applyToCollection($queryBuilder, $nameGenerator, ResourceInterface::class, new Get()); + } + + public function test_does_not_apply_conditions_to_collection_for_admin_api_section(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $section = $this->createMock(AdminApiSection::class); + + $this->sectionProvider->method('getSection')->willReturn($section); + $this->userContext->expects($this->never())->method('getUser'); + $queryBuilder->expects($this->never())->method('andWhere'); + + $this->extension->applyToCollection($queryBuilder, $nameGenerator, PaymentRequestInterface::class, new Get()); + } + + public function test_does_not_apply_conditions_to_collection_for_anonymous_user(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + + $this->sectionProvider->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->method('getUser')->willReturn(null); + $queryBuilder->expects($this->never())->method('getRootAliases'); + $queryBuilder->expects($this->never())->method('andWhere'); + + $this->extension->applyToCollection($queryBuilder, $nameGenerator, PaymentRequestInterface::class, new Get()); + } + + public function test_does_not_apply_conditions_to_collection_for_non_shop_user(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $user = $this->createMock(AdminUserInterface::class); + + $this->sectionProvider->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->method('getUser')->willReturn($user); + $queryBuilder->expects($this->never())->method('getRootAliases'); + $queryBuilder->expects($this->never())->method('andWhere'); + + $this->extension->applyToCollection($queryBuilder, $nameGenerator, PaymentRequestInterface::class, new Get()); + } + + public function test_applies_conditions_to_collection(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $user = $this->createMock(ShopUserInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $expr = $this->createMock(Expr::class); + $exprEq = $this->createMock(Comparison::class); + + $user->method('getCustomer')->willReturn($customer); + $this->sectionProvider->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->method('getUser')->willReturn($user); + + $queryBuilder->expects($this->once())->method('getRootAliases')->willReturn(['o']); + + $nameGenerator->expects($this->exactly(2)) + ->method('generateJoinAlias') + ->willReturnCallback(fn (string $alias) => $alias); + $nameGenerator->expects($this->once()) + ->method('generateParameterName') + ->with('customer') + ->willReturn('customer'); + + $queryBuilder->expects($this->exactly(2)) + ->method('innerJoin') + ->willReturnCallback(function (string $join, string $alias) use ($queryBuilder): QueryBuilder { + static $calls = 0; + ++$calls; + if (1 === $calls) { + self::assertSame('o.payment', $join); + self::assertSame('payment', $alias); + } else { + self::assertSame('payment.order', $join); + self::assertSame('order', $alias); + } + + return $queryBuilder; + }); + + $queryBuilder->expects($this->once())->method('expr')->willReturn($expr); + $expr->expects($this->once())->method('eq')->with('order.customer', ':customer')->willReturn($exprEq); + + $queryBuilder->expects($this->once())->method('andWhere')->with($exprEq)->willReturn($queryBuilder); + $queryBuilder->expects($this->once())->method('setParameter')->with('customer', $customer)->willReturn($queryBuilder); + + $this->extension->applyToCollection($queryBuilder, $nameGenerator, PaymentRequestInterface::class, new Get()); + } + + public function test_does_not_apply_conditions_to_item_for_unsupported_resource(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + + $this->userContext->expects($this->never())->method('getUser'); + $queryBuilder->expects($this->never())->method('andWhere'); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, ResourceInterface::class, [], new Get()); + } + + public function test_does_not_apply_conditions_to_item_for_admin_api_section(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $section = $this->createMock(AdminApiSection::class); + + $this->sectionProvider->method('getSection')->willReturn($section); + $this->userContext->expects($this->never())->method('getUser'); + $queryBuilder->expects($this->never())->method('andWhere'); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, PaymentRequestInterface::class, [], new Get()); + } + + public function test_does_not_apply_conditions_to_item_for_anonymous_user(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + + $this->sectionProvider->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->method('getUser')->willReturn(null); + $queryBuilder->expects($this->never())->method('getRootAliases'); + $queryBuilder->expects($this->never())->method('andWhere'); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, PaymentRequestInterface::class, [], new Get()); + } + + public function test_does_not_apply_conditions_to_item_for_non_shop_user(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $user = $this->createMock(AdminUserInterface::class); + + $this->sectionProvider->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->method('getUser')->willReturn($user); + $queryBuilder->expects($this->never())->method('getRootAliases'); + $queryBuilder->expects($this->never())->method('andWhere'); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, PaymentRequestInterface::class, [], new Get()); + } + + public function test_applies_conditions_to_item(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $user = $this->createMock(ShopUserInterface::class); + $customer = $this->createMock(CustomerInterface::class); + $expr = $this->createMock(Expr::class); + $exprEq = $this->createMock(Comparison::class); + + $user->method('getCustomer')->willReturn($customer); + $this->sectionProvider->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->method('getUser')->willReturn($user); + + $queryBuilder->expects($this->once())->method('getRootAliases')->willReturn(['o']); + + $nameGenerator->expects($this->exactly(2)) + ->method('generateJoinAlias') + ->willReturnCallback(fn (string $alias) => $alias); + $nameGenerator->expects($this->once()) + ->method('generateParameterName') + ->with('customer') + ->willReturn('customer'); + + $queryBuilder->expects($this->exactly(2)) + ->method('innerJoin') + ->willReturnCallback(function (string $join, string $alias) use ($queryBuilder): QueryBuilder { + static $calls = 0; + ++$calls; + if (1 === $calls) { + self::assertSame('o.payment', $join); + self::assertSame('payment', $alias); + } else { + self::assertSame('payment.order', $join); + self::assertSame('order', $alias); + } + + return $queryBuilder; + }); + + $queryBuilder->expects($this->once())->method('expr')->willReturn($expr); + $expr->expects($this->once())->method('eq')->with('order.customer', ':customer')->willReturn($exprEq); + + $queryBuilder->expects($this->once())->method('andWhere')->with($exprEq)->willReturn($queryBuilder); + $queryBuilder->expects($this->once())->method('setParameter')->with('customer', $customer)->willReturn($queryBuilder); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, PaymentRequestInterface::class, [], new Get()); + } +} diff --git a/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/VisitorBasedExtensionTest.php b/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/VisitorBasedExtensionTest.php new file mode 100644 index 0000000000..7fa25c0c46 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/tests/Doctrine/ORM/QueryExtension/Shop/PaymentRequest/VisitorBasedExtensionTest.php @@ -0,0 +1,140 @@ +sectionProvider = $this->createMock(SectionProviderInterface::class); + $this->userContext = $this->createMock(UserContextInterface::class); + $this->extension = new VisitorBasedExtension($this->sectionProvider, $this->userContext); + } + + public function test_does_not_apply_conditions_for_unsupported_resource(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + + $this->userContext->expects($this->never())->method('getUser'); + $queryBuilder->expects($this->never())->method('getRootAliases'); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, ResourceInterface::class, [], new Get()); + } + + public function test_does_not_apply_conditions_for_admin_section(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + + $this->sectionProvider->method('getSection')->willReturn(new AdminApiSection()); + $this->userContext->expects($this->never())->method('getUser'); + $queryBuilder->expects($this->never())->method('getRootAliases'); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, PaymentRequestInterface::class, [], new Get()); + } + + public function test_does_not_apply_conditions_for_authenticated_user(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + + $this->sectionProvider->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->method('getUser')->willReturn($this->createMock(ShopUserInterface::class)); + $queryBuilder->expects($this->never())->method('getRootAliases'); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, PaymentRequestInterface::class, [], new Get()); + } + + public function test_applies_guest_order_filter_for_anonymous_user(): void + { + $queryBuilder = $this->createMock(QueryBuilder::class); + $nameGenerator = $this->createMock(QueryNameGeneratorInterface::class); + $expr = $this->createMock(Expr::class); + $orX = $this->createMock(Orx::class); + + $this->sectionProvider->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->method('getUser')->willReturn(null); + + $queryBuilder->expects($this->once())->method('getRootAliases')->willReturn(['o']); + + $nameGenerator->expects($this->exactly(4)) + ->method('generateJoinAlias') + ->willReturnCallback(fn (string $alias) => $alias); + + $queryBuilder->expects($this->exactly(2)) + ->method('innerJoin') + ->willReturnCallback(function (string $join, string $alias) use ($queryBuilder): QueryBuilder { + static $calls = 0; + ++$calls; + if (1 === $calls) { + self::assertSame('o.payment', $join); + self::assertSame('payment', $alias); + } else { + self::assertSame('payment.order', $join); + self::assertSame('order', $alias); + } + + return $queryBuilder; + }); + + $queryBuilder->expects($this->exactly(2)) + ->method('leftJoin') + ->willReturnCallback(function (string $join, string $alias) use ($queryBuilder): QueryBuilder { + static $calls = 0; + ++$calls; + if (1 === $calls) { + self::assertSame('order.customer', $join); + self::assertSame('customer', $alias); + } else { + self::assertSame('customer.user', $join); + self::assertSame('user', $alias); + } + + return $queryBuilder; + }); + + $queryBuilder->expects($this->exactly(3))->method('expr')->willReturn($expr); + $expr->expects($this->exactly(2)) + ->method('isNull') + ->willReturnCallback(fn (string $alias) => $alias . ' IS NULL'); + $expr->expects($this->once())->method('orX')->with('customer IS NULL', 'user IS NULL')->willReturn($orX); + + $queryBuilder->expects($this->once())->method('andWhere')->with($orX)->willReturn($queryBuilder); + + $this->extension->applyToItem($queryBuilder, $nameGenerator, PaymentRequestInterface::class, [], new Get()); + } +} diff --git a/src/Sylius/Bundle/ApiBundle/tests/StateProvider/Shop/Payment/PaymentRequest/ItemProviderTest.php b/src/Sylius/Bundle/ApiBundle/tests/StateProvider/Shop/Payment/PaymentRequest/ItemProviderTest.php new file mode 100644 index 0000000000..6e70efa4c4 --- /dev/null +++ b/src/Sylius/Bundle/ApiBundle/tests/StateProvider/Shop/Payment/PaymentRequest/ItemProviderTest.php @@ -0,0 +1,224 @@ +sectionProvider = $this->createMock(SectionProviderInterface::class); + $this->userContext = $this->createMock(UserContextInterface::class); + $this->paymentRequestRepository = $this->createMock(PaymentRequestRepositoryInterface::class); + $this->finalizedPaymentRequestChecker = $this->createMock(FinalizedPaymentRequestCheckerInterface::class); + $this->itemProvider = new ItemProvider( + $this->sectionProvider, + $this->paymentRequestRepository, + $this->finalizedPaymentRequestChecker, + $this->userContext, + ); + } + + public function testAStateProvider(): void + { + self::assertInstanceOf(ProviderInterface::class, $this->itemProvider); + } + + public function testThrowsAnExceptionIfOperationClassIsNotPaymentRequest(): void + { + /** @var Operation&MockObject $operation */ + $operation = $this->createMock(Operation::class); + $operation->expects(self::once())->method('getClass')->willReturn(\stdClass::class); + + self::expectException(\InvalidArgumentException::class); + + $this->itemProvider->provide($operation); + } + + public function testThrowsAnExceptionIfOperationIsNotPut(): void + { + /** @var Operation&MockObject $operation */ + $operation = $this->createMock(Operation::class); + $operation->expects(self::once())->method('getClass')->willReturn(PaymentRequestInterface::class); + + self::expectException(\InvalidArgumentException::class); + + $this->itemProvider->provide($operation); + } + + public function testThrowsAnExceptionIfSectionIsNotShopApiSection(): void + { + $this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new AdminApiSection()); + + self::expectException(\InvalidArgumentException::class); + + $this->itemProvider->provide($this->putOperation(), [], []); + } + + public function testReturnsNothingIfPaymentRequestIsNotFound(): void + { + $this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new ShopApiSection()); + $this->paymentRequestRepository->expects(self::once())->method('find')->with('hash')->willReturn(null); + $this->userContext->expects(self::never())->method('getUser'); + $this->finalizedPaymentRequestChecker->expects(self::never())->method('isFinal'); + + self::assertNull($this->itemProvider->provide($this->putOperation(), ['hash' => 'hash'], [])); + } + + public function testReturnsNothingIfShopUserHasNoCustomer(): void + { + /** @var ShopUserInterface&MockObject $user */ + $user = $this->createMock(ShopUserInterface::class); + $user->expects(self::once())->method('getCustomer')->willReturn(null); + + $this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->expects(self::once())->method('getUser')->willReturn($user); + $this->paymentRequestRepository->expects(self::once())->method('find')->with('hash')->willReturn( + $this->createMock(PaymentRequestInterface::class), + ); + $this->finalizedPaymentRequestChecker->expects(self::never())->method('isFinal'); + + self::assertNull($this->itemProvider->provide($this->putOperation(), ['hash' => 'hash'], [])); + } + + public function testReturnsNothingIfPaymentRequestIsNotOwnedByTheCustomer(): void + { + $customer = $this->stubAuthenticatedCustomer(); + + /** @var OrderInterface&MockObject $order */ + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($this->createMock(CustomerInterface::class)); + + /** @var PaymentInterface&MockObject $payment */ + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getOrder')->willReturn($order); + + /** @var PaymentRequestInterface&MockObject $paymentRequest */ + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + + $this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new ShopApiSection()); + $this->paymentRequestRepository->expects(self::once())->method('find')->with('hash')->willReturn($paymentRequest); + $this->finalizedPaymentRequestChecker->expects(self::never())->method('isFinal'); + + self::assertNull($this->itemProvider->provide($this->putOperation(), ['hash' => 'hash'], [])); + self::assertNotSame($customer, $order->getCustomer()); + } + + public function testReturnsNothingIfPaymentRequestIsOwnedByTheCustomerButInFinalState(): void + { + $paymentRequest = $this->createOwnedPaymentRequest($this->stubAuthenticatedCustomer()); + + $this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new ShopApiSection()); + $this->paymentRequestRepository->expects(self::once())->method('find')->with('hash')->willReturn($paymentRequest); + $this->finalizedPaymentRequestChecker->expects(self::once())->method('isFinal')->with($paymentRequest)->willReturn(true); + + self::assertNull($this->itemProvider->provide($this->putOperation(), ['hash' => 'hash'], [])); + } + + public function testReturnsThePaymentRequestWhenOwnedByTheCustomerAndNotFinal(): void + { + $paymentRequest = $this->createOwnedPaymentRequest($this->stubAuthenticatedCustomer()); + + $this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new ShopApiSection()); + $this->paymentRequestRepository->expects(self::once())->method('find')->with('hash')->willReturn($paymentRequest); + $this->finalizedPaymentRequestChecker->expects(self::once())->method('isFinal')->with($paymentRequest)->willReturn(false); + + self::assertSame($paymentRequest, $this->itemProvider->provide($this->putOperation(), ['hash' => 'hash'], [])); + } + + public function testWithoutUserContextSkipsOwnershipCheck(): void + { + $itemProvider = new ItemProvider( + $this->sectionProvider, + $this->paymentRequestRepository, + $this->finalizedPaymentRequestChecker, + ); + + /** @var PaymentRequestInterface&MockObject $paymentRequest */ + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + + $this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new ShopApiSection()); + $this->userContext->expects(self::never())->method('getUser'); + $this->paymentRequestRepository->expects(self::once())->method('find')->with('hash')->willReturn($paymentRequest); + $this->finalizedPaymentRequestChecker->expects(self::once())->method('isFinal')->with($paymentRequest)->willReturn(false); + + self::assertSame($paymentRequest, $itemProvider->provide($this->putOperation(), ['hash' => 'hash'], [])); + } + + private function putOperation(): Put + { + return new Put(class: PaymentRequestInterface::class, name: 'put'); + } + + private function stubAuthenticatedCustomer(): CustomerInterface&MockObject + { + /** @var CustomerInterface&MockObject $customer */ + $customer = $this->createMock(CustomerInterface::class); + + /** @var ShopUserInterface&MockObject $user */ + $user = $this->createMock(ShopUserInterface::class); + $user->method('getCustomer')->willReturn($customer); + + $this->userContext->expects(self::once())->method('getUser')->willReturn($user); + + return $customer; + } + + private function createOwnedPaymentRequest(CustomerInterface $customer): MockObject&PaymentRequestInterface + { + /** @var OrderInterface&MockObject $order */ + $order = $this->createMock(OrderInterface::class); + $order->method('getCustomer')->willReturn($customer); + + /** @var PaymentInterface&MockObject $payment */ + $payment = $this->createMock(PaymentInterface::class); + $payment->method('getOrder')->willReturn($order); + + /** @var PaymentRequestInterface&MockObject $paymentRequest */ + $paymentRequest = $this->createMock(PaymentRequestInterface::class); + $paymentRequest->method('getPayment')->willReturn($payment); + + return $paymentRequest; + } +} diff --git a/tests/Api/Shop/PaymentRequestsTest.php b/tests/Api/Shop/PaymentRequestsTest.php index c39aaabde9..f1c08ba35c 100644 --- a/tests/Api/Shop/PaymentRequestsTest.php +++ b/tests/Api/Shop/PaymentRequestsTest.php @@ -258,6 +258,199 @@ final class PaymentRequestsTest extends JsonApiTestCase $this->assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); } + /** @test */ + public function it_does_not_allow_an_anonymous_customer_to_get_a_payment_request_owned_by_a_customer(): void + { + $paymentRequest = $this->loadPaymentRequestOwnedByOliver(); + + $this->client->request( + method: 'GET', + uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()), + server: $this->headerBuilder()->withJsonLdAccept()->build(), + ); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND); + } + + /** @test */ + public function it_does_not_allow_a_customer_to_get_a_payment_request_owned_by_another_customer(): void + { + $paymentRequest = $this->loadPaymentRequestOwnedByOliver(); + + $this->client->request( + method: 'GET', + uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()), + server: $this->headerBuilder()->withJsonLdAccept()->withShopUserAuthorization('dave@doe.com')->build(), + ); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND); + } + + /** @test */ + public function it_does_not_allow_an_anonymous_customer_to_update_a_payment_request_owned_by_a_customer(): void + { + $paymentRequest = $this->loadPaymentRequestOwnedByOliver(); + + $this->client->request( + method: 'PUT', + uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()), + server: $this->headerBuilder()->withJsonLdAccept()->withJsonLdContentType()->build(), + content: json_encode([ + 'payload' => [ + 'target_path' => 'https://attacker.tld/target-path', + 'after_path' => 'https://attacker.tld/after-path', + ], + ], \JSON_THROW_ON_ERROR), + ); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND); + } + + /** @test */ + public function it_does_not_allow_a_customer_to_update_a_payment_request_owned_by_another_customer(): void + { + $paymentRequest = $this->loadPaymentRequestOwnedByOliver(); + + $this->client->request( + method: 'PUT', + uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()), + server: $this->headerBuilder()->withJsonLdAccept()->withJsonLdContentType()->withShopUserAuthorization('dave@doe.com')->build(), + content: json_encode([ + 'payload' => [ + 'target_path' => 'https://attacker.tld/target-path', + 'after_path' => 'https://attacker.tld/after-path', + ], + ], \JSON_THROW_ON_ERROR), + ); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND); + } + + /** @test */ + public function it_does_not_allow_a_shop_user_to_get_a_payment_request_for_a_guest_order(): void + { + $this->setUpDefaultGetHeaders(); + + $fixtures = $this->loadFixturesFromFiles([ + 'authentication/shop_user.yaml', + 'channel/channel.yaml', + 'payment_method.yaml', + 'payment_request/payment_request.yaml', + 'payment_request/order.yaml', + ]); + + /** @var PaymentRequestInterface $paymentRequest */ + $paymentRequest = $fixtures['payment_request_capture']; + + $this->client->request( + method: 'GET', + uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()), + server: $this->headerBuilder()->withJsonLdAccept()->withShopUserAuthorization('oliver@doe.com')->build(), + ); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND); + } + + /** @test */ + public function it_does_not_allow_a_shop_user_to_update_a_payment_request_for_a_guest_order(): void + { + $fixtures = $this->loadFixturesFromFiles([ + 'authentication/shop_user.yaml', + 'channel/channel.yaml', + 'payment_method.yaml', + 'payment_request/payment_request.yaml', + 'payment_request/order.yaml', + ]); + + /** @var PaymentRequestInterface $paymentRequest */ + $paymentRequest = $fixtures['payment_request_capture']; + + $this->client->request( + method: 'PUT', + uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()), + server: $this->headerBuilder()->withJsonLdAccept()->withJsonLdContentType()->withShopUserAuthorization('oliver@doe.com')->build(), + content: json_encode([ + 'payload' => [ + 'target_path' => 'https://myshop.tld/target-path', + 'after_path' => 'https://myshop.tld/after-path', + ], + ], \JSON_THROW_ON_ERROR), + ); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_NOT_FOUND); + } + + /** @test */ + public function it_allows_an_anonymous_user_to_get_a_payment_request_for_a_guest_order(): void + { + $this->setUpDefaultGetHeaders(); + + $fixtures = $this->loadFixturesFromFiles([ + 'channel/channel.yaml', + 'payment_method.yaml', + 'payment_request/payment_request.yaml', + 'payment_request/order.yaml', + ]); + + /** @var PaymentRequestInterface $paymentRequest */ + $paymentRequest = $fixtures['payment_request_capture']; + + $this->client->request( + method: 'GET', + uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()), + server: $this->headerBuilder()->withJsonLdAccept()->build(), + ); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_OK); + } + + /** @test */ + public function it_allows_an_anonymous_user_to_update_a_payment_request_for_a_guest_order(): void + { + $this->setUpDefaultGetHeaders(); + + $fixtures = $this->loadFixturesFromFiles([ + 'channel/channel.yaml', + 'gateway_config_payment_request.yaml', + 'payment_method.yaml', + 'payment_request/payment_request.yaml', + 'payment_request/order.yaml', + ]); + + /** @var PaymentRequestInterface $paymentRequest */ + $paymentRequest = $fixtures['payment_request_capture']; + + $this->client->request( + method: 'PUT', + uri: sprintf('/api/v2/shop/payment-requests/%s', $paymentRequest->getHash()), + server: $this->headerBuilder()->withJsonLdAccept()->withJsonLdContentType()->build(), + content: json_encode([ + 'payload' => [ + 'target_path' => 'https://myshop.tld/new-target-path', + 'after_path' => 'https://myshop.tld/new-after-path', + ], + ], \JSON_THROW_ON_ERROR), + ); + + $this->assertResponseCode($this->client->getResponse(), Response::HTTP_OK); + } + + private function loadPaymentRequestOwnedByOliver(): PaymentRequestInterface + { + $fixtures = $this->loadFixturesFromFiles([ + 'authentication/shop_user.yaml', + 'channel/channel.yaml', + 'payment_method.yaml', + 'payment_request/payment_request.yaml', + 'payment_request/order_with_customer.yaml', + ]); + + /** @var PaymentRequestInterface $paymentRequest */ + $paymentRequest = $fixtures['payment_request_capture']; + + return $paymentRequest; + } + public static function createPaymentRequestProvider(): iterable { yield 'Payment request' => [