Check payment request ownership

This commit is contained in:
TheMilek 2026-05-12 08:11:39 +02:00
parent c6ea41803e
commit 77f3d23992
No known key found for this signature in database
GPG key ID: A3F43F426B5286AA
10 changed files with 987 additions and 36 deletions

View file

@ -1,3 +1,22 @@
# UPGRADE FROM `2.1.14` TO `2.1.15`
## 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 `2.1.10` TO `2.1.11`
### Constructor signature changes

View file

@ -0,0 +1,93 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Shop\PaymentRequest;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Bundle\ApiBundle\SectionResolver\ShopApiSection;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
final readonly class ShopUserBasedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private SectionProviderInterface $sectionProvider,
private UserContextInterface $userContext,
) {
}
/**
* @param array<array-key, mixed> $context
*/
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->filterByCustomer($queryBuilder, $queryNameGenerator, $resourceClass);
}
/**
* @param array<array-key, mixed> $identifiers
* @param array<array-key, mixed> $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())
;
}
}

View file

@ -0,0 +1,76 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Shop\PaymentRequest;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Bundle\ApiBundle\SectionResolver\ShopApiSection;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
final readonly class VisitorBasedExtension implements QueryItemExtensionInterface
{
public function __construct(
private SectionProviderInterface $sectionProvider,
private UserContextInterface $userContext,
) {
}
/**
* @param array<array-key, mixed> $identifiers
* @param array<array-key, mixed> $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),
),
)
;
}
}

View file

@ -280,5 +280,24 @@
<tag name="api_platform.doctrine.orm.query_extension.collection" />
<tag name="api_platform.doctrine.orm.query_extension.item" />
</service>
<service
id="sylius_api.doctrine.orm.query_extension.shop.payment_request.shop_user_based"
class="Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Shop\PaymentRequest\ShopUserBasedExtension"
>
<argument type="service" id="sylius.section_resolver.uri_based" />
<argument type="service" id="sylius_api.context.user.token_based" />
<tag name="api_platform.doctrine.orm.query_extension.collection" />
<tag name="api_platform.doctrine.orm.query_extension.item" />
</service>
<service
id="sylius_api.doctrine.orm.query_extension.shop.payment_request.visitor_based"
class="Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Shop\PaymentRequest\VisitorBasedExtension"
>
<argument type="service" id="sylius.section_resolver.uri_based" />
<argument type="service" id="sylius_api.context.user.token_based" />
<tag name="api_platform.doctrine.orm.query_extension.item" />
</service>
</services>
</container>

View file

@ -76,6 +76,7 @@
<argument type="service" id="sylius.section_resolver.uri_based" />
<argument type="service" id="sylius.repository.payment_request"/>
<argument type="service" id="sylius.checker.finalized_payment_request"/>
<argument type="service" id="sylius_api.context.user.token_based" />
<tag name="api_platform.state_provider" priority="10"/>
</service>

View file

@ -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,6 +40,7 @@ final readonly class ItemProvider implements ProviderInterface
private SectionProviderInterface $sectionProvider,
private PaymentRequestRepositoryInterface $paymentRequestRepository,
private FinalizedPaymentRequestCheckerInterface $finalizedPaymentRequestChecker,
private ?UserContextInterface $userContext = null,
) {
}
@ -45,14 +51,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();
}
}

View file

@ -0,0 +1,246 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Tests\Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Shop\PaymentRequest;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Get;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\Query\Expr\Comparison;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Shop\PaymentRequest\ShopUserBasedExtension;
use Sylius\Bundle\ApiBundle\SectionResolver\AdminApiSection;
use Sylius\Bundle\ApiBundle\SectionResolver\ShopApiSection;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Sylius\Component\Core\Model\AdminUserInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Resource\Model\ResourceInterface;
final class ShopUserBasedExtensionTest extends TestCase
{
private ShopUserBasedExtension $extension;
private MockObject&SectionProviderInterface $sectionProvider;
private MockObject&UserContextInterface $userContext;
protected function setUp(): void
{
$this->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());
}
}

View file

@ -0,0 +1,140 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Tests\Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Shop\PaymentRequest;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Get;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\Query\Expr\Orx;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Bundle\ApiBundle\Doctrine\ORM\QueryExtension\Shop\PaymentRequest\VisitorBasedExtension;
use Sylius\Bundle\ApiBundle\SectionResolver\AdminApiSection;
use Sylius\Bundle\ApiBundle\SectionResolver\ShopApiSection;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
use Sylius\Resource\Model\ResourceInterface;
final class VisitorBasedExtensionTest extends TestCase
{
private VisitorBasedExtension $extension;
private MockObject&SectionProviderInterface $sectionProvider;
private MockObject&UserContextInterface $userContext;
protected function setUp(): void
{
$this->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());
}
}

View file

@ -18,11 +18,16 @@ use ApiPlatform\Metadata\Put;
use ApiPlatform\State\ProviderInterface;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Bundle\ApiBundle\SectionResolver\AdminApiSection;
use Sylius\Bundle\ApiBundle\SectionResolver\ShopApiSection;
use Sylius\Bundle\ApiBundle\StateProvider\Shop\Payment\PaymentRequest\ItemProvider;
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;
@ -30,6 +35,8 @@ final class ItemProviderTest extends TestCase
{
private MockObject&SectionProviderInterface $sectionProvider;
private MockObject&UserContextInterface $userContext;
private MockObject&PaymentRequestRepositoryInterface $paymentRequestRepository;
private FinalizedPaymentRequestCheckerInterface&MockObject $finalizedPaymentRequestChecker;
@ -39,10 +46,17 @@ final class ItemProviderTest extends TestCase
protected function setUp(): void
{
parent::setUp();
$this->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->itemProvider = new ItemProvider(
$this->sectionProvider,
$this->paymentRequestRepository,
$this->finalizedPaymentRequestChecker,
$this->userContext,
);
}
public function testAStateProvider(): void
@ -50,63 +64,161 @@ final class ItemProviderTest extends TestCase
self::assertInstanceOf(ProviderInterface::class, $this->itemProvider);
}
public function testThrowsAnExceptionIfOperationClassIsNotPayment(): void
public function testThrowsAnExceptionIfOperationClassIsNotPaymentRequest(): void
{
/** @var Operation|MockObject $operationMock */
$operationMock = $this->createMock(Operation::class);
$operationMock->expects(self::once())->method('getClass')->willReturn(\stdClass::class);
/** @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($operationMock);
$this->itemProvider->provide($operation);
}
public function testThrowsAnExceptionIfOperationIsNotPut(): void
{
/** @var Operation|MockObject $operationMock */
$operationMock = $this->createMock(Operation::class);
$operationMock->expects(self::once())->method('getClass')->willReturn(PaymentRequestInterface::class);
/** @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($operationMock);
$this->itemProvider->provide($operation);
}
public function testThrowsAnExceptionIfSectionIsNotShopApiSection(): void
{
$operation = new Put(class: PaymentRequestInterface::class, name: 'put');
$this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new AdminApiSection());
self::expectException(\InvalidArgumentException::class);
$this->itemProvider->provide($operation, [], []);
$this->itemProvider->provide($this->putOperation(), [], []);
}
public function testReturnsNothingIfPaymentRequestIsNotFound(): void
{
$hash = 'hash';
$operation = new Put(class: PaymentRequestInterface::class, name: 'put');
$this->sectionProvider->expects(self::once())->method('getSection')->willReturn(new ShopApiSection());
$this->paymentRequestRepository->expects(self::once())->method('find')->with($hash)->willReturn(null);
$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');
$this->assertNull($this->itemProvider->provide($operation, ['hash' => $hash], []));
self::assertNull($this->itemProvider->provide($this->putOperation(), ['hash' => 'hash'], []));
}
public function testReturnsNothingIfPaymentRequestIsInFinalState(): void
public function testReturnsNothingIfShopUserHasNoCustomer(): void
{
/** @var PaymentRequestInterface|MockObject $paymentRequestMock */
$paymentRequestMock = $this->createMock(PaymentRequestInterface::class);
$hash = 'hash';
$operation = new Put(class: PaymentRequestInterface::class, name: 'put');
/** @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->paymentRequestRepository->expects(self::once())->method('find')->with($hash)->willReturn($paymentRequestMock);
$this->finalizedPaymentRequestChecker->expects(self::once())->method('isFinal')->with($paymentRequestMock)->willReturn(true);
$this->assertNull($this->itemProvider->provide($operation, ['hash' => $hash], []));
$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 testReturnsPaymentRequestByHash(): void
public function testReturnsNothingIfPaymentRequestIsNotOwnedByTheCustomer(): void
{
/** @var PaymentRequestInterface|MockObject $paymentRequestMock */
$paymentRequestMock = $this->createMock(PaymentRequestInterface::class);
$hash = 'hash';
$operation = new Put(class: PaymentRequestInterface::class, name: 'put');
$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($paymentRequestMock);
$this->finalizedPaymentRequestChecker->expects(self::once())->method('isFinal')->with($paymentRequestMock)->willReturn(false);
self::assertSame($paymentRequestMock, $this->itemProvider->provide($operation, ['hash' => $hash], []));
$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;
}
}

View file

@ -253,6 +253,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' => [