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
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ inline retry notice (HTTP 502). The controller resolves the api key from the pay
config and fetches via `Quickpay/ClientFactory`.

`Command/DoctorCommand` (`setono:sylius-quickpay:doctor`) machine-checks the README's Troubleshooting
section per configured gateway: api key ping, private key HMAC self-test (`CallbackValidator`), agreement
section per configured gateway: api key (via the shared `Quickpay/ApiKeyVerifier` ping-then-payments
probe), private key HMAC self-test (`CallbackValidator`), agreement
existence (`GET agreements/{id}`, failing open on permission errors), order prefix length + cross-gateway
uniqueness, and notify-route registration; `--live` creates a money-less test payment and attempts the
link PUT to surface the missing-permission 403. Non-zero exit on any failed check; warnings don't fail.
Expand Down Expand Up @@ -195,8 +196,11 @@ in that folder) and need `assets:install` in the host app.
configs stored under the pre-2.0 option names (`apikey`/`privatekey`/`agreement` →
`api_key`/`private_key`/`agreement_id`, the last normalized to int/null for the integer field) and folds
a stored `auto_capture` into `use_authorize` (enabled → `false`). The `api_key` carries a `QuickpayCredentials` constraint
(sylius group) whose validator pings Quickpay via `Quickpay/ClientFactory` (symfony/http-client
capped at 5s) — an explicit 401/403 raises a violation, anything else fails open.
(sylius group) whose validator verifies the key through `Quickpay/ApiKeyVerifier` (shared with the
doctor command; backed by `Quickpay/ClientFactory`, symfony/http-client capped at 5s): ping first,
falling back to a one-item `/payments` read because Quickpay answers 401 on `/ping` both for an
invalid key and for a valid key whose api user lacks the `/ping` permission (verified live) — an
explicit 401/403 from the probe raises a violation, anything else fails open.
Sylius' admin form theme ignores Symfony's `help_html` option, so the
`payment_methods` docs link renders through the plugin's own form theme
(`Resources/views/form/theme.html.twig`, scoped to that field's block prefix and registered by
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,11 @@ out the gateway configuration:
| Synchronized operations | Run capture, refund and cancel synchronously instead of relying on the Quickpay callback |
| Branding id | *(optional)* The payment window branding to use |

When you save the payment method, the plugin verifies the API key against Quickpay's API (a lightweight
ping) and rejects the form if Quickpay rejects the key — a typo'd key is caught immediately instead of by
the first customer whose checkout fails. If Quickpay cannot be reached, the check is skipped so an outage
never blocks saving.
When you save the payment method, the plugin verifies the API key against Quickpay's API and rejects the
form if Quickpay rejects the key — a typo'd key is caught immediately instead of by the first customer
whose checkout fails. A valid key whose API user lacks the `/ping` permission is verified through a
`/payments` read instead, so a locked-down API user never blocks saving; neither does an unreachable
Quickpay — the check fails open on anything but an explicit rejection.

## How it works

Expand Down
31 changes: 9 additions & 22 deletions src/Command/DoctorCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
use Setono\Quickpay\Exception\UnauthorizedException;
use Setono\Quickpay\Request\Payment\CreateLinkRequest;
use Setono\Quickpay\Request\Payment\CreatePaymentRequest;
use Setono\Quickpay\Request\Payment\PaymentsQuery;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyResolver;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerification;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifierInterface;
use Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface;
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
Expand Down Expand Up @@ -49,6 +50,7 @@ final class DoctorCommand extends Command
*/
public function __construct(
private readonly ClientFactoryInterface $clientFactory,
private readonly ApiKeyVerifierInterface $apiKeyVerifier,
private readonly RepositoryInterface $gatewayConfigRepository,
private readonly RouterInterface $router,
) {
Expand Down Expand Up @@ -164,26 +166,8 @@ private function checkApiKey(SymfonyStyle $io, array $config): ?ClientInterface
return null;
}

$client = $this->clientFactory->create($apiKey);

try {
$client->ping();

$this->ok($io, 'The api key is accepted by Quickpay');

return $client;
} catch (UnauthorizedException|ForbiddenException) {
// Quickpay answers 401 both for an invalid key and for a valid key whose api user
// lacks the /ping permission (verified live), so fall through to a request every
// integration needs anyway
} catch (\Throwable $e) {
$this->warn($io, sprintf('Could not verify the api key, Quickpay did not answer: %s', $e->getMessage()));

return null;
}

try {
$client->payments()->getPage(new PaymentsQuery(pageSize: 1));
$verification = $this->apiKeyVerifier->verify($apiKey);
} catch (UnauthorizedException|ForbiddenException) {
$this->fail($io, 'Quickpay rejects the api key — use the API user\'s key (Settings → Users in the Quickpay manager), not a Payment Window agreement\'s');

Expand All @@ -194,9 +178,12 @@ private function checkApiKey(SymfonyStyle $io, array $config): ?ClientInterface
return null;
}

$this->ok($io, 'The api key is accepted by Quickpay (verified via /payments — the api user lacks the /ping permission, which is harmless)');
$this->ok($io, match ($verification) {
ApiKeyVerification::ViaPing => 'The api key is accepted by Quickpay',
ApiKeyVerification::ViaPayments => 'The api key is accepted by Quickpay (verified via /payments — the api user lacks the /ping permission, which is harmless)',
});

return $client;
return $this->clientFactory->create($apiKey);
}

/**
Expand Down
16 changes: 16 additions & 0 deletions src/Quickpay/ApiKeyVerification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Quickpay;

/**
* How an api key was verified. Both cases mean the key is valid — ViaPayments additionally tells
* that the api user lacks the /ping permission, which is harmless but worth surfacing in
* diagnostics. A key that could not be verified never yields a verification: the verifier throws.
*/
enum ApiKeyVerification
{
case ViaPing;
case ViaPayments;
}
33 changes: 33 additions & 0 deletions src/Quickpay/ApiKeyVerifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Quickpay;

use Setono\Quickpay\Exception\ForbiddenException;
use Setono\Quickpay\Exception\UnauthorizedException;
use Setono\Quickpay\Request\Payment\PaymentsQuery;

final class ApiKeyVerifier implements ApiKeyVerifierInterface
{
public function __construct(private readonly ClientFactoryInterface $clientFactory)
{
}

public function verify(string $apiKey): ApiKeyVerification
{
$client = $this->clientFactory->create($apiKey);

try {
$client->ping();

return ApiKeyVerification::ViaPing;
} catch (UnauthorizedException|ForbiddenException) {
// Either an invalid key or a valid key without the /ping permission — /payments decides
}

$client->payments()->getPage(new PaymentsQuery(pageSize: 1));

return ApiKeyVerification::ViaPayments;
}
}
22 changes: 22 additions & 0 deletions src/Quickpay/ApiKeyVerifierInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Quickpay;

use Setono\Quickpay\Exception\ForbiddenException;
use Setono\Quickpay\Exception\UnauthorizedException;

interface ApiKeyVerifierInterface
{
/**
* Verifies the api key against Quickpay and tells how it was verified. Quickpay answers 401 on
* /ping both for an invalid key and for a valid key whose api user merely lacks the /ping
* permission (verified live), so a rejected ping falls back to a one-item /payments read — a
* permission every integration needs.
*
* @throws UnauthorizedException|ForbiddenException when Quickpay rejects the key
* @throws \Throwable when Quickpay did not answer, so nothing is proven either way
*/
public function verify(string $apiKey): ApiKeyVerification;
}
10 changes: 9 additions & 1 deletion src/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,24 @@
<service id="Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface"
alias="Setono\SyliusQuickpayPlugin\Quickpay\ClientFactory"/>

<service id="Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifier">
<argument type="service" id="Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface"/>
</service>

<service id="Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifierInterface"
alias="Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifier"/>

<!-- Validator -->
<service id="Setono\SyliusQuickpayPlugin\Validator\Constraints\QuickpayCredentialsValidator">
<argument type="service" id="Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface"/>
<argument type="service" id="Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifierInterface"/>

<tag name="validator.constraint_validator"/>
</service>

<!-- Command -->
<service id="Setono\SyliusQuickpayPlugin\Command\DoctorCommand">
<argument type="service" id="Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface"/>
<argument type="service" id="Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifierInterface"/>
<argument type="service" id="sylius.repository.gateway_config"/>
<argument type="service" id="router"/>

Expand Down
6 changes: 3 additions & 3 deletions src/Validator/Constraints/QuickpayCredentialsValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@

use Setono\Quickpay\Exception\ForbiddenException;
use Setono\Quickpay\Exception\UnauthorizedException;
use Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifierInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

final class QuickpayCredentialsValidator extends ConstraintValidator
{
public function __construct(private readonly ClientFactoryInterface $clientFactory)
public function __construct(private readonly ApiKeyVerifierInterface $apiKeyVerifier)
{
}

Expand All @@ -32,7 +32,7 @@ public function validate(mixed $value, Constraint $constraint): void
}

try {
$this->clientFactory->create($value)->ping();
$this->apiKeyVerifier->verify($value);
} catch (UnauthorizedException|ForbiddenException) {
$this->context->buildViolation($constraint->message)->addViolation();
} catch (\Throwable) {
Expand Down
3 changes: 3 additions & 0 deletions tests/Command/DoctorCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Setono\Quickpay\Exception\ForbiddenException;
use Setono\Quickpay\Exception\NotFoundException;
use Setono\SyliusQuickpayPlugin\Command\DoctorCommand;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifier;
use Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface;
use Setono\SyliusQuickpayPlugin\Tests\Quickpay\QueuedResponsesHttpClient;
use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
Expand Down Expand Up @@ -532,8 +533,10 @@ private static function routes(bool $withNotifyRoute): RouteCollection
private function executeCommand(array $input = []): CommandTester
{
$application = new Application();
// A real verifier over the same client factory keeps the probe part of what is tested
$application->add(new DoctorCommand(
$this->clientFactory->reveal(),
new ApiKeyVerifier($this->clientFactory->reveal()),
$this->gatewayConfigRepository->reveal(),
$this->router->reveal(),
));
Expand Down
18 changes: 6 additions & 12 deletions tests/Form/Type/GatewayConfigurationTypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
use Nyholm\Psr7\Response;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Setono\Quickpay\Client\ClientInterface;
use Setono\Quickpay\Exception\UnauthorizedException;
use Setono\SyliusQuickpayPlugin\Form\Type\GatewayConfigurationType;
use Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerification;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifierInterface;
use Setono\SyliusQuickpayPlugin\Validator\Constraints\QuickpayCredentialsValidator;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Form\FormExtensionInterface;
Expand All @@ -32,17 +32,11 @@ final class GatewayConfigurationTypeTest extends TypeTestCase
*/
protected function getExtensions(): array
{
$client = $this->prophesize(ClientInterface::class);
$client->ping()->willReturn(true);
$apiKeyVerifier = $this->prophesize(ApiKeyVerifierInterface::class);
$apiKeyVerifier->verify(Argument::type('string'))->willReturn(ApiKeyVerification::ViaPing);
$apiKeyVerifier->verify('rejected-api-key')->willThrow(new UnauthorizedException(new Response(401)));

$rejectingClient = $this->prophesize(ClientInterface::class);
$rejectingClient->ping()->willThrow(new UnauthorizedException(new Response(401)));

$clientFactory = $this->prophesize(ClientFactoryInterface::class);
$clientFactory->create(Argument::type('string'))->willReturn($client);
$clientFactory->create('rejected-api-key')->willReturn($rejectingClient);

$credentialsValidator = new QuickpayCredentialsValidator($clientFactory->reveal());
$credentialsValidator = new QuickpayCredentialsValidator($apiKeyVerifier->reveal());

$validator = Validation::createValidatorBuilder()
->setConstraintValidatorFactory(new class($credentialsValidator) implements ConstraintValidatorFactoryInterface {
Expand Down
99 changes: 99 additions & 0 deletions tests/Quickpay/ApiKeyVerifierTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Setono\SyliusQuickpayPlugin\Tests\Quickpay;

use Nyholm\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Setono\Quickpay\Client\Client;
use Setono\Quickpay\Exception\InternalServerErrorException;
use Setono\Quickpay\Exception\UnauthorizedException;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerification;
use Setono\SyliusQuickpayPlugin\Quickpay\ApiKeyVerifier;
use Setono\SyliusQuickpayPlugin\Quickpay\ClientFactoryInterface;

final class ApiKeyVerifierTest extends TestCase
{
use ProphecyTrait;

/**
* @test
*/
public function it_verifies_a_key_with_the_ping_permission(): void
{
$verifier = $this->createVerifier(new Response(200, [], '{}'));

self::assertSame(ApiKeyVerification::ViaPing, $verifier->verify('the-api-key'));
}

/**
* Quickpay answers 401 on /ping both for an invalid key and for a valid key whose api user
* lacks the /ping permission (verified live), so a rejected ping is decided by /payments
*
* @test
*/
public function it_verifies_a_key_without_the_ping_permission_through_payments(): void
{
$verifier = $this->createVerifier(
new Response(401, [], '{"message": "Invalid API key"}'),
new Response(200, [], '[]'),
);

self::assertSame(ApiKeyVerification::ViaPayments, $verifier->verify('the-api-key'));
}

/**
* @test
*/
public function it_throws_when_quickpay_rejects_the_key_on_both_endpoints(): void
{
$verifier = $this->createVerifier(
new Response(401, [], '{"message": "Invalid API key"}'),
new Response(401, [], '{"message": "Invalid API key"}'),
);

$this->expectException(UnauthorizedException::class);

$verifier->verify('the-api-key');
}

/**
* @test
*/
public function it_propagates_an_unanswered_ping(): void
{
$verifier = $this->createVerifier(new Response(500, [], '{"message": "boom"}'));

$this->expectException(InternalServerErrorException::class);

$verifier->verify('the-api-key');
}

/**
* @test
*/
public function it_propagates_an_unanswered_payments_fallback(): void
{
$verifier = $this->createVerifier(
new Response(401, [], '{"message": "Invalid API key"}'),
new Response(500, [], '{"message": "boom"}'),
);

$this->expectException(InternalServerErrorException::class);

$verifier->verify('the-api-key');
}

private function createVerifier(Response ...$responses): ApiKeyVerifier
{
$clientFactory = $this->prophesize(ClientFactoryInterface::class);
$clientFactory
->create('the-api-key')
->willReturn(new Client('the-api-key', new QueuedResponsesHttpClient(...$responses)))
;

return new ApiKeyVerifier($clientFactory->reveal());
}
}
Loading
Loading