Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/ApiClient/PayPlugApiClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ public function __construct(
) {
}

/**
* Channel-ambiguous: since PRE-3628 several enabled gateway configs may share a factory name —
* one per channel — and findOneBy() then returns an arbitrary one of them, so the client this
* returns may carry another channel's account credentials.
*
* @internal Kept off {@see PayPlugApiClientFactoryInterface} so no application class can reach
* it; the sole remaining callers are the `payplug_sylius_payplug_plugin.api_client.*`
* service-factory definitions in config/services/client.xml, which are #[Autowire]d
* into seven services that have no payment method in scope. Use
* {@see self::createForPaymentMethod()} everywhere else. Removed once those
* singletons are made channel-aware — the open half of PRE-3682.
*/
public function create(string $factoryName): PayPlugApiClientInterface
{
/** @var GatewayConfigInterface|null $gatewayConfig */
Expand Down
9 changes: 7 additions & 2 deletions src/ApiClient/PayPlugApiClientFactoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@

interface PayPlugApiClientFactoryInterface
{
public function create(string $factoryName): PayPlugApiClientInterface;

/**
* The only way to obtain a client from application code. Resolving one by factory name is
* deliberately absent: since PRE-3628 several enabled gateway configs may share a factory name
* — one per channel — so a name-based lookup returns an arbitrary one of them and can sign a
* request for channel A with channel B's account credentials. Keeping that signature off this
* interface makes the compiler, rather than review, the guard against reintroducing it.
*/
public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): PayPlugApiClientInterface;
}
9 changes: 4 additions & 5 deletions src/Controller/IntegratedPaymentController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace PayPlug\SyliusPayPlugPlugin\Controller;

use Doctrine\ORM\EntityManagerInterface;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Creator\PayPlugPaymentDataCreator;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
Expand Down Expand Up @@ -35,7 +35,7 @@ public function __construct(
private RepositoryInterface $paymentMethodRepository,
private OrderRepositoryInterface $orderRepository,
private PayPlugPaymentDataCreator $paymentDataCreator,
private PayPlugApiClientFactory $apiClientFactory,
private PayPlugApiClientFactoryInterface $apiClientFactory,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
) {
Expand Down Expand Up @@ -74,8 +74,7 @@ public function initPaymentAction(Request $request, int $paymentMethodId): Respo
}

$payment->setMethod($paymentMethod);
$factoryName = $paymentMethod->getGatewayConfig()?->getFactoryName();
if (PayPlugGatewayFactory::FACTORY_NAME !== $factoryName) {
if (PayPlugGatewayFactory::FACTORY_NAME !== $paymentMethod->getGatewayConfig()?->getFactoryName()) {
throw new BadRequestHttpException('Unsupported payment method of Integrated Payment');
}

Expand All @@ -84,7 +83,7 @@ public function initPaymentAction(Request $request, int $paymentMethodId): Respo
$paymentData['integration'] = PayPlugApiClientInterface::INTEGRATED_PAYMENT_INTEGRATION;
$this->logger->debug('Payplug Payment data for creation', $paymentData->getArrayCopy());

$apiClient = $this->apiClientFactory->create($factoryName);
$apiClient = $this->apiClientFactory->createForPaymentMethod($paymentMethod);
$payplugPayment = $apiClient->createPayment($paymentData->getArrayCopy());
$this->logger->debug('PayPlug payment created', (array) $payplugPayment);

Expand Down
4 changes: 2 additions & 2 deletions src/Controller/IpnAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public function __invoke(Request $request): JsonResponse

if (
!$paymentMethod->getGatewayConfig() instanceof GatewayConfigInterface ||
!\in_array($factoryName = $paymentMethod->getGatewayConfig()->getFactoryName(), [
!\in_array($paymentMethod->getGatewayConfig()->getFactoryName(), [
PayPlugGatewayFactory::FACTORY_NAME,
OneyGatewayFactory::FACTORY_NAME,
BancontactGatewayFactory::FACTORY_NAME,
Expand All @@ -100,7 +100,7 @@ public function __invoke(Request $request): JsonResponse
return new JsonResponse(null, Response::HTTP_UNAUTHORIZED);
}

$this->payPlugApiClient = $this->apiClientFactory->create($factoryName);
$this->payPlugApiClient = $this->apiClientFactory->createForPaymentMethod($paymentMethod);

try {
$resource = $this->payPlugApiClient->treat($input);
Expand Down
4 changes: 2 additions & 2 deletions src/Controller/OneClickAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace PayPlug\SyliusPayPlugPlugin\Controller;

use PayPlug\SyliusPayPlugPlugin\Action\Api\ApiAwareTrait;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use Payum\Core\ApiAwareInterface;
use Payum\Core\GatewayAwareInterface;
use Payum\Core\GatewayAwareTrait;
Expand Down Expand Up @@ -33,7 +33,7 @@ class OneClickAction extends AbstractController implements GatewayAwareInterface
public function __construct(
private PaymentRepositoryInterface $paymentRepository,
private Payum $payum,
private PayPlugApiClientFactory $payPlugApiClientFactory,
private PayPlugApiClientFactoryInterface $payPlugApiClientFactory,
) {
}

Expand Down
4 changes: 2 additions & 2 deletions src/Gateway/Validator/Constraints/IsOneyEnabledValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints;

use Payplug\Exception\UnauthorizedException;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\Checker\OneyChecker;
use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException;
use PayPlug\SyliusPayPlugPlugin\Gateway\OneyGatewayFactory;
Expand All @@ -18,7 +18,7 @@

final class IsOneyEnabledValidator extends ConstraintValidator
{
public function __construct(private PayPlugApiClientFactory $apiClientFactory)
public function __construct(private PayPlugApiClientFactoryInterface $apiClientFactory)
{
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
namespace PayPlug\SyliusPayPlugPlugin\Gateway\Validator\Constraints;

use Payplug\Exception\UnauthorizedException;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

final class PayplugPermissionValidator extends ConstraintValidator
{
public function __construct(private PayPlugApiClientFactory $apiClientFactory)
public function __construct(private PayPlugApiClientFactoryInterface $apiClientFactory)
{
}

Expand Down
4 changes: 2 additions & 2 deletions src/PaymentProcessing/CaptureAuthorizedPaymentProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace PayPlug\SyliusPayPlugPlugin\PaymentProcessing;

use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use PayPlug\SyliusPayPlugPlugin\Handler\PaymentNotificationHandler;
Expand All @@ -18,7 +18,7 @@
final class CaptureAuthorizedPaymentProcessor
{
public function __construct(
private PayPlugApiClientFactory $apiClientFactory,
private PayPlugApiClientFactoryInterface $apiClientFactory,
private PaymentNotificationHandler $paymentNotificationHandler,
) {
}
Expand Down
39 changes: 28 additions & 11 deletions src/Provider/SupportedMethodsProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ public function provide(
$memoKey = $this->accountMemoKey($gatewayConfig);
$account = $accounts[$memoKey] ??= $this->clientFactory->createForPaymentMethod($paymentMethod)->getAccount();

$authorizedCurrencies = $this->resolveAuthorizedCurrencies($account, $factoryName);
$allowedCountries = $this->resolveAllowedCountries($account, $factoryName);
$authorizedCurrencies = $this->resolveAuthorizedCurrencies($account, $gatewayConfig);
$allowedCountries = $this->resolveAllowedCountries($account, $gatewayConfig);

if ($billingCountryCode !== null && $allowedCountries !== [] && !\in_array($billingCountryCode, $allowedCountries, true)) {
unset($supportedMethods[$key]);
Expand Down Expand Up @@ -189,29 +189,30 @@ private function accountMemoKey(GatewayConfigInterface $gatewayConfig): string
}

/**
* Both resolvers below read the factory name off the gateway config the $account was fetched
* for, rather than off provide()'s $factoryName argument. The loop guard above makes the two
* equal today, but keeping the account payload and the key used to index it sourced from the
* same config is what stops the pair drifting apart if that guard is ever relaxed.
*
* @param array<array-key, mixed> $account
*
* @return array<string, array{min_amount: int, max_amount: int}>
*/
private function resolveAuthorizedCurrencies(array $account, string $factoryName): array
private function resolveAuthorizedCurrencies(array $account, GatewayConfigInterface $gatewayConfig): array
{
$underscorePos = strpos($factoryName, '_');
$paymentMethodKey = false !== $underscorePos ? substr($factoryName, $underscorePos + 1) : null;

return $this->amountRangeResolver->resolve($account, $paymentMethodKey);
return $this->amountRangeResolver->resolve($account, $this->paymentMethodKey($gatewayConfig));
}

/**
* @param array<array-key, mixed> $account
*/
private function resolveAllowedCountries(array $account, string $factoryName): array
private function resolveAllowedCountries(array $account, GatewayConfigInterface $gatewayConfig): array
{
$underscorePos = strpos($factoryName, '_');
if ($underscorePos === false) {
$pmKey = $this->paymentMethodKey($gatewayConfig);
if (null === $pmKey) {
return [];
}

$pmKey = substr($factoryName, $underscorePos + 1);
$paymentMethods = $account['payment_methods'] ?? [];
Assert::isArray($paymentMethods);
$pmData = $paymentMethods[$pmKey] ?? [];
Expand All @@ -226,4 +227,20 @@ private function resolveAllowedCountries(array $account, string $factoryName): a

return $allowedCountries;
}

/**
* The `/account` payload keys each PPRO method under the factory name's suffix — `payplug_oney`
* is advertised as `oney`. A suffix-less factory name (`payplug`) is the card gateway, which
* has no such sub-payload.
*/
private function paymentMethodKey(GatewayConfigInterface $gatewayConfig): ?string
{
// provide()'s loop guard has already matched this config against a non-null factory name,
// so the null coalesce is unreachable from there; it keeps the helper total for any later
// caller, and an empty name carries no suffix anyway.
$factoryName = $gatewayConfig->getFactoryName() ?? '';
$underscorePos = strpos($factoryName, '_');

return false !== $underscorePos ? substr($factoryName, $underscorePos + 1) : null;
}
}
4 changes: 2 additions & 2 deletions src/Resolver/PaymentStateResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Doctrine\ORM\EntityManagerInterface;
use Payplug\Resource\Payment;
use Payplug\Resource\PaymentAuthorization;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\Gateway\PayPlugGatewayFactory;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Component\Core\Model\PaymentInterface;
Expand All @@ -19,7 +19,7 @@ final class PaymentStateResolver implements PaymentStateResolverInterface
{
public function __construct(
private StateMachineInterface $stateMachine,
private PayPlugApiClientFactory $payPlugApiClientFactory,
private PayPlugApiClientFactoryInterface $payPlugApiClientFactory,
private EntityManagerInterface $paymentEntityManager,
) {
}
Expand Down
7 changes: 6 additions & 1 deletion tests/Behat/Mocker/PayPlugApiClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactoryInterface;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use Sylius\Component\Payment\Model\PaymentMethodInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

class PayPlugApiClientFactory implements PayPlugApiClientFactoryInterface
Expand All @@ -22,7 +23,11 @@ public function __construct(ContainerInterface $container, string $serviceName)
$this->serviceName = $serviceName;
}

public function create(string $factoryName, ?string $key = null): PayPlugApiClientInterface
/**
* The Behat suites stub one PayPlug account for the whole scenario, so the payment method is
* ignored here — the mocked client is the same whichever one is passed.
*/
public function createForPaymentMethod(PaymentMethodInterface $paymentMethod): PayPlugApiClientInterface
{
return new PayPlugApiClient($this->container, $this->serviceName);
}
Expand Down
72 changes: 69 additions & 3 deletions tests/PHPUnit/ApiClient/PayPlugApiClientFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Tests\PayPlug\SyliusPayPlugPlugin\PHPUnit\ApiClient;

use Doctrine\Common\Collections\ArrayCollection;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientFactory;
use PayPlug\SyliusPayPlugPlugin\ApiClient\PayPlugApiClientInterface;
use PayPlug\SyliusPayPlugPlugin\Exception\GatewayConfigurationException;
Expand All @@ -13,6 +14,8 @@
use PayplugUnifiedCore\Contracts\ITokenCache;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Sylius\Component\Core\Model\ChannelInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface as CorePaymentMethodInterface;
use Sylius\Component\Payment\Model\GatewayConfigInterface;
use Sylius\Component\Payment\Model\PaymentMethodInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
Expand Down Expand Up @@ -160,13 +163,76 @@ public function testCreateForPaymentMethod_withCachedToken_doesNotCallTheTokenEn
$this->factory->createForPaymentMethod($paymentMethod);
}

private function buildGatewayConfig(bool $isLive): GatewayConfigInterface&MockObject
// -------------------------------------------------------------------------
// createForPaymentMethod() — credentials are scoped to the payment method, not the factory name
// -------------------------------------------------------------------------

/**
* Since PRE-3628 several enabled gateway configs may share a factory name — one per channel.
* `findOneBy(['factoryName' => ...])` then resolves to an arbitrary one of them, so a client
* built that way can sign a request for channel A with channel B's account credentials.
* createForPaymentMethod() must read the credentials off the payment method's own gateway
* config and never consult the repository; routing it back through that lookup is the
* production change that makes this test fail.
*/
public function testCreateForPaymentMethod_withTwoChannelsSharingAFactoryName_usesEachChannelsOwnCredentials(): void
{
$frConfig = $this->buildGatewayConfig(isLive: false, clientId: 'client_fr', clientSecret: 'secret_fr');
$deConfig = $this->buildGatewayConfig(isLive: false, clientId: 'client_de', clientSecret: 'secret_de');

// Both rows are enabled and carry factoryName 'payplug', so Doctrine is free to return
// either one; the FR row stands in for "whichever one it picked".
$this->gatewayConfigRepository->method('findOneBy')->willReturn($frConfig);

$this->tokenCache->method('get')->willReturn(null); // cache miss for both client ids

/** @var list<string> $sentCredentials */
$sentCredentials = [];
$this->oauthHttpClient->method('post')->willReturnCallback(
function (string $url, array $formParams, array $headers = []) use (&$sentCredentials): array {
$sentCredentials[] = $headers['Authorization'];

return [
'status' => 200,
'body' => json_encode(['access_token' => 'jwt', 'expires_in' => 300, 'token_type' => 'Bearer']),
];
},
);

$this->factory->createForPaymentMethod($this->buildPaymentMethodOnChannel('FR', $frConfig));
$this->factory->createForPaymentMethod($this->buildPaymentMethodOnChannel('DE', $deConfig));

self::assertSame([
'Basic ' . base64_encode('client_fr:secret_fr'),
'Basic ' . base64_encode('client_de:secret_de'),
], $sentCredentials);
}

private function buildPaymentMethodOnChannel(
string $channelCode,
GatewayConfigInterface $gatewayConfig,
): PaymentMethodInterface&MockObject {
$channel = $this->createMock(ChannelInterface::class);
$channel->method('getCode')->willReturn($channelCode);

$paymentMethod = $this->createMock(CorePaymentMethodInterface::class);
$paymentMethod->method('isEnabled')->willReturn(true);
$paymentMethod->method('getChannels')->willReturn(new ArrayCollection([$channel]));
$paymentMethod->method('getGatewayConfig')->willReturn($gatewayConfig);

return $paymentMethod;
}

private function buildGatewayConfig(
bool $isLive,
string $clientId = 'client',
string $clientSecret = 'secret',
): GatewayConfigInterface&MockObject {
$gatewayConfig = $this->createMock(GatewayConfigInterface::class);
$gatewayConfig->method('getConfig')->willReturn([
'live' => $isLive,
'live_client' => ['client_id' => 'client_live', 'client_secret' => 'secret_live'],
'test_client' => ['client_id' => 'client_test', 'client_secret' => 'secret_test'],
'live_client' => ['client_id' => $clientId, 'client_secret' => $clientSecret],
'test_client' => ['client_id' => $clientId, 'client_secret' => $clientSecret],
]);
$gatewayConfig->method('getFactoryName')->willReturn('payplug');

Expand Down
Loading