Fixes axepta with tests
This commit is contained in:
commit
6b3dc60ed5
15 changed files with 794 additions and 442 deletions
|
|
@ -10,7 +10,7 @@ use App\Http\Controllers\Controller;
|
|||
use App\Http\Requests\Club\Shop\ShopBuyRequest;
|
||||
use App\Jobs\Arcadia\SendOrderToArcadia;
|
||||
use App\Jobs\Shop\SendShopMail;
|
||||
use App\Lib\Axepta\AxeptaPayment;
|
||||
use App\Lib\Axepta\AxeptaPaymentService;
|
||||
use App\Lib\Axepta\Enums\PaymentResponse;
|
||||
use App\Lib\Axepta\Objects\AxeptaOrder;
|
||||
use App\Models\DeliveryAddress;
|
||||
|
|
@ -26,7 +26,7 @@ use Illuminate\Support\Facades\Log;
|
|||
class ShopController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AxeptaPayment $aPayment
|
||||
private readonly AxeptaPaymentService $aPayment
|
||||
) {
|
||||
return parent::__construct();
|
||||
}
|
||||
|
|
|
|||
51
app/Lib/Axepta/AxeptaApiGateway.php
Normal file
51
app/Lib/Axepta/AxeptaApiGateway.php
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Lib\Axepta;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AxeptaApiGateway
|
||||
{
|
||||
private Client $client;
|
||||
|
||||
public function __construct(AxeptaConfig $config, ?Client $client = null)
|
||||
{
|
||||
$this->client = $client ?? new Client([
|
||||
'base_uri' => $config->endpoint,
|
||||
'timeout' => 5.0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function post(string $uri, array $payload, string $token): ResponseInterface
|
||||
{
|
||||
return $this->client->post($uri, [
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Content-Type' => 'application/json',
|
||||
'Idempotency-Key' => (string) Str::uuid()
|
||||
],
|
||||
'json' => $payload
|
||||
]);
|
||||
}
|
||||
|
||||
public function get(string $uri, string $token): ResponseInterface
|
||||
{
|
||||
return $this->client->get($uri, [
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Content-Type' => 'application/json',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function postForm(string $uri, array $formParams): ResponseInterface
|
||||
{
|
||||
return $this->client->post($uri, [
|
||||
'form_params' => $formParams
|
||||
]);
|
||||
}
|
||||
}
|
||||
35
app/Lib/Axepta/AxeptaConfig.php
Normal file
35
app/Lib/Axepta/AxeptaConfig.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Lib\Axepta;
|
||||
|
||||
final readonly class AxeptaConfig
|
||||
{
|
||||
public function __construct(
|
||||
#[\SensitiveParameter]
|
||||
public string $endpoint,
|
||||
#[\SensitiveParameter]
|
||||
public string $merchantId,
|
||||
#[\SensitiveParameter]
|
||||
public string $apiKey,
|
||||
#[\SensitiveParameter]
|
||||
public string $hmac,
|
||||
/** @param array<string,mixed> $axeptaConfig */
|
||||
#[\SensitiveParameter]
|
||||
public array $checkoutConfig = []
|
||||
) {
|
||||
if (empty(trim($endpoint))) {
|
||||
throw new \DomainException('Endpoint cannot be empty');
|
||||
}
|
||||
if (empty(trim($merchantId))) {
|
||||
throw new \DomainException('Merchant ID cannot be empty');
|
||||
}
|
||||
if (empty(trim($apiKey))) {
|
||||
throw new \DomainException('API Key cannot be empty');
|
||||
}
|
||||
if (empty(trim($hmac))) {
|
||||
throw new \DomainException('HMAC cannot be empty');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,401 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Lib\Axepta;
|
||||
|
||||
use App\Lib\Axepta\Objects\AxeptaOrder;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AxeptaPayment
|
||||
{
|
||||
private const int PERMITTED_TIMESTAMP_SKEW_IN_MINUTES = 5;
|
||||
|
||||
private const string USING_API_VERSION = 'v1';
|
||||
|
||||
/**
|
||||
* Guzzle Client instance
|
||||
*
|
||||
* @var GuzzleHttp\Client
|
||||
*/
|
||||
private \GuzzleHttp\Client $client;
|
||||
|
||||
/**
|
||||
* Additionnal or Update Axepta configuration
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private array $axeptaCheckoutConfig;
|
||||
|
||||
/**
|
||||
* AxeptaBearerToken cache key
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $cacheKey = "AxeptaBearerToken";
|
||||
|
||||
/**
|
||||
* Construct method
|
||||
*
|
||||
* @param string $endpoint Axepta Endpoint
|
||||
* @param string $merchantId Axepta Merchant ID
|
||||
* @param string $apiKey Axepta Secret API Key
|
||||
* @param string $hmac Axepta HMAC
|
||||
* @param array $axeptaConfig Additionnal or Update Axepta configuration
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
#[\SensitiveParameter]
|
||||
private string $endpoint,
|
||||
#[\SensitiveParameter]
|
||||
private string $merchantId,
|
||||
#[\SensitiveParameter]
|
||||
private string $apiKey,
|
||||
#[\SensitiveParameter]
|
||||
private string $hmac,
|
||||
#[\SensitiveParameter]
|
||||
private array $axeptaConfig = []
|
||||
) {
|
||||
$this->endpoint = $endpoint;
|
||||
$this->merchantId = $merchantId;
|
||||
$this->apiKey = $apiKey;
|
||||
$this->hmac = $hmac;
|
||||
$this->client = new Client([
|
||||
'base_uri' => $this->endpoint,
|
||||
'timeout' => 5.0,
|
||||
]);
|
||||
$this->axeptaCheckoutConfig = [
|
||||
...[
|
||||
"language" => "fr",
|
||||
"amount" => [
|
||||
"currency" => "EUR",
|
||||
],
|
||||
"captureMethod" => [
|
||||
"type" => "AUTOMATIC"
|
||||
],
|
||||
"allowedPaymentMethods" => [
|
||||
"CARD"
|
||||
],
|
||||
"paymentMethods" => [
|
||||
"card" => [
|
||||
"subType" => ["VISA", "MASTERCARD"],
|
||||
],
|
||||
]
|
||||
],
|
||||
...$axeptaConfig
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* getAccessToken method
|
||||
* https://axeptabnpparibas-docs.redocly.app/authentication/authorizationservice
|
||||
*
|
||||
* @return string
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
private function getAccessToken(): string
|
||||
{
|
||||
if (Cache::has($this->cacheKey)) {
|
||||
return Cache::get($this->cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client->post('/authorization/oauth/token', [
|
||||
'form_params' => [
|
||||
'grant_type' => 'client_credentials',
|
||||
'client_id' => $this->merchantId,
|
||||
'client_secret' => $this->apiKey
|
||||
]
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new \RuntimeException('Token request failed');
|
||||
}
|
||||
|
||||
$responseBody = json_decode($response->getBody()->getContents());
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($responseBody->access_token) || !isset($responseBody->expires_in)) {
|
||||
throw new \RuntimeException('Invalid token response');
|
||||
}
|
||||
|
||||
Cache::put($this->cacheKey, $responseBody->access_token, $responseBody->expires_in);
|
||||
|
||||
return $responseBody->access_token;
|
||||
} catch (RequestException $e) {
|
||||
$errorBody = $e->hasResponse() ? $e->getResponse()->getBody()->getContents() : '{}';
|
||||
$errorData = json_decode($errorBody, true);
|
||||
$message = $errorData['error'] ?? $errorData['responseDescription'] ?? $e->getMessage();
|
||||
throw new \RuntimeException("Axepta token error: {$message}", 0, $e);
|
||||
} //end try
|
||||
}
|
||||
|
||||
/**
|
||||
* getCheckoutUrl method
|
||||
*
|
||||
* @param \App\Lib\Axepta\Objects\AxeptaOrder $order
|
||||
* @param string $returnUrl
|
||||
* @param string $cancelUrl
|
||||
* @return string
|
||||
* @throws \RuntimeException
|
||||
*/
|
||||
public function getCheckoutUrl(AxeptaOrder $order, string $returnUrl, string $cancelUrl, string $webhookUrl): string
|
||||
{
|
||||
$payload = [
|
||||
"transId" => $this->generateTransId(),
|
||||
"urls" => [
|
||||
"return" => $returnUrl,
|
||||
"cancel" => $cancelUrl,
|
||||
"webhook" => $webhookUrl
|
||||
],
|
||||
"amount" => [
|
||||
"value" => $order->amount
|
||||
],
|
||||
"order" => [
|
||||
"merchantReference" => $order->reference,
|
||||
"creationDate" => gmdate('c'),
|
||||
// ISO 8601 now
|
||||
],
|
||||
"customerInfo" => [
|
||||
"email" => $order->customerEmail,
|
||||
]
|
||||
];
|
||||
|
||||
try {
|
||||
$response = $this->client->post('/api/v2/payments/sessions', [
|
||||
'headers' => [
|
||||
'Authorization' => sprintf(
|
||||
"Bearer %s",
|
||||
$this->getAccessToken()
|
||||
),
|
||||
'Content-Type' => 'application/json',
|
||||
'Idempotency-Key' => (string) Str::uuid()
|
||||
],
|
||||
'json' => array_merge_recursive($payload, $this->axeptaCheckoutConfig)
|
||||
// Automatically JSON encodes and sets Content-Type
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() !== 202) {
|
||||
throw new \RuntimeException('Session creation failed');
|
||||
}
|
||||
|
||||
$responseBody = json_decode($response->getBody()->getContents());
|
||||
|
||||
if (!isset($responseBody->_links->redirect->href)) {
|
||||
throw new \RuntimeException('Missing redirect URL in response');
|
||||
}
|
||||
|
||||
return $responseBody->_links->redirect->href;
|
||||
} catch (RequestException $e) {
|
||||
throw new \RuntimeException("Payment session error: " . $e->getMessage(), 0, $e);
|
||||
} //end try
|
||||
}
|
||||
|
||||
/**
|
||||
* generateTransId method
|
||||
*
|
||||
* @param integer $length
|
||||
* @return string
|
||||
*/
|
||||
private function generateTransId(int $length = 64): string
|
||||
{
|
||||
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
$bytes = random_bytes($length);
|
||||
$result = '';
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$result .= $chars[ord($bytes[$i]) % strlen($chars)];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getPaymentStatusById(string $paymentId): string|null
|
||||
{
|
||||
try {
|
||||
$response = $this->client->get(
|
||||
sprintf(
|
||||
'/api/v2/payments/getByPayId/%s',
|
||||
$paymentId
|
||||
),
|
||||
[
|
||||
'headers' => [
|
||||
'Authorization' => sprintf(
|
||||
"Bearer %s",
|
||||
$this->getAccessToken()
|
||||
),
|
||||
'Content-Type' => 'application/json',
|
||||
]
|
||||
]
|
||||
);
|
||||
|
||||
$responseBody = json_decode($response->getBody()->getContents());
|
||||
return $responseBody->responseCode;
|
||||
} catch (RequestException $e) {
|
||||
if ($e->hasResponse()) {
|
||||
$response = $e->getResponse();
|
||||
$responseBody = json_decode($response->getBody()->getContents());
|
||||
return $responseBody->responseCode;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} //end try
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if is valid webhook access
|
||||
*
|
||||
* From Axepta 2.0 Doc
|
||||
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
|
||||
*
|
||||
* X-Paygate-Signature-Version Signature format version (currently v1)
|
||||
* X-Paygate-Timestamp Unix epoch timestamp (seconds since 1970-01-01T00:00:00Z, UTC), the timestamp is within ±5 minutes of your local clock.
|
||||
* X-Paygate-Signature v1=<hex-hmac>
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return boolean
|
||||
*/
|
||||
public function isValidWebhookAccess(Request $request): bool
|
||||
{
|
||||
$signatureVersion = $this->getHeaderValue($request, 'x-paygate-signature-version');
|
||||
$signature = $this->getHeaderValue($request, 'x-paygate-signature');
|
||||
$timestamp = $this->getHeaderValue($request, 'x-paygate-timestamp');
|
||||
$timestamDatetime = Carbon::createFromTimestampUTC($timestamp);
|
||||
$rawPayload = $request->getContent(false);
|
||||
|
||||
$this->logger->debug(
|
||||
'Received an IPN call', [
|
||||
'request_signature_version' => $signatureVersion,
|
||||
'request_signature' => $signature,
|
||||
'request_timestamp' => $timestamp,
|
||||
'request_payload' => $rawPayload
|
||||
]
|
||||
);
|
||||
|
||||
return $this->isValidSignatureVersion($signatureVersion) &&
|
||||
$this->requestTimestampIsAcceptable($timestamDatetime) &&
|
||||
$this->isValidSignature($signature, $timestamDatetime, $rawPayload);
|
||||
}
|
||||
|
||||
/** Get for sure a string value for a specific header name */
|
||||
private function getHeaderValue(Request $request, string $headerName): string
|
||||
{
|
||||
$headerValue = Arr::wrap($request->header($headerName));
|
||||
$headerValue = Arr::map($headerValue, fn($v) => is_scalar($v) ? strval($v) : '');
|
||||
|
||||
return Str::trim(Arr::join($headerValue, ' '));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check signature version
|
||||
*
|
||||
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
|
||||
*
|
||||
* @param string|null $version
|
||||
* @return boolean
|
||||
*/
|
||||
private function isValidSignatureVersion(string $version): bool
|
||||
{
|
||||
$versionIsValid = $version === self::USING_API_VERSION;
|
||||
|
||||
if (!$versionIsValid) {
|
||||
// NOTE: If this occurs it shall be handled
|
||||
$this->logger->error(
|
||||
"Version is not accepted",
|
||||
[
|
||||
'request_version' => $version,
|
||||
'accepted_version' => self::USING_API_VERSION
|
||||
]
|
||||
);
|
||||
}
|
||||
return $version === self::USING_API_VERSION;
|
||||
}
|
||||
|
||||
private function requestTimestampIsAcceptable(CarbonInterface $timestamp): bool
|
||||
{
|
||||
$now = Carbon::now('UTC');
|
||||
$calculatedDiff = $now->diffInMinutes(date: $timestamp, absolute: true);
|
||||
$timestampIsInSkew = $calculatedDiff <= self::PERMITTED_TIMESTAMP_SKEW_IN_MINUTES;
|
||||
|
||||
if (!$timestampIsInSkew) {
|
||||
// NOTE: If this occurs it shall be handled
|
||||
$this->logger->error(
|
||||
"request timestamp skew is not not matched",
|
||||
[
|
||||
'request_timestamp' => $timestamp,
|
||||
'diff' => $calculatedDiff,
|
||||
'max_skew_in_minutes' => self::PERMITTED_TIMESTAMP_SKEW_IN_MINUTES
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return $timestampIsInSkew;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check signature
|
||||
*
|
||||
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
|
||||
*
|
||||
* @param string $signature
|
||||
* @param \Carbon\CarbonInterface $timestamp
|
||||
* @param string $rawPayload
|
||||
* @return boolean
|
||||
*/
|
||||
private function isValidSignature(string $signature, CarbonInterface $timestamp, string $rawPayload): bool
|
||||
{
|
||||
$providedSignature = $this->extractSignatureFromCurrentVersion($signature);
|
||||
|
||||
if (!$providedSignature) {
|
||||
// NOTE: If this occurs it shall be handled
|
||||
$this->logger->error(
|
||||
"Could not extract signature from '{$signature}'"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
$signedPayload = "{$timestamp}.{$rawPayload}";
|
||||
$expectedSignature = hash_hmac('sha256', $signedPayload, $this->hmac);
|
||||
$signatureIsValid = hash_equals($expectedSignature, $providedSignature);
|
||||
|
||||
if (!$signatureIsValid) {
|
||||
// NOTE: If this occurs it could be a fraudulent request
|
||||
$this->logger->debug(
|
||||
"{Could not validate that expected signature '{$expectedSignature}' matches the request signature '{$signature}'",
|
||||
[
|
||||
'request_signature' => $signature,
|
||||
'request_timestamp' => $timestamp,
|
||||
'request_payload' => $rawPayload,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
return $signatureIsValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the hex value after "v1=" from the signature header.
|
||||
*
|
||||
* https://axepta.computop.com/display/DOCBNP/Gestion+des+callbacks+et+notification+de+paiement
|
||||
*
|
||||
* @param string $signature
|
||||
* @return string|null
|
||||
*/
|
||||
private function extractSignatureFromCurrentVersion(string $signature): ?string
|
||||
{
|
||||
$signatures = explode(',', $signature);
|
||||
$filteredSignatures = array_filter($signatures, function (string $key) {
|
||||
return $key === self::USING_API_VERSION;
|
||||
}, \ARRAY_FILTER_USE_KEY);
|
||||
|
||||
$hex = $filteredSignatures[0] ?? '';
|
||||
|
||||
// NOTE: SHA256 hex = 64 chars
|
||||
return strlen($hex) === 64 ? $hex : null;
|
||||
}
|
||||
}
|
||||
124
app/Lib/Axepta/AxeptaPaymentService.php
Normal file
124
app/Lib/Axepta/AxeptaPaymentService.php
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Lib\Axepta;
|
||||
|
||||
use App\Lib\Axepta\Objects\AxeptaOrder;
|
||||
use App\Lib\Axepta\AxeptaSignatureVerifier;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
|
||||
class AxeptaPaymentService
|
||||
{
|
||||
private array $axeptaCheckoutConfig;
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger,
|
||||
readonly AxeptaConfig $axeptaConfig,
|
||||
private readonly AxeptaApiGateway $gateway,
|
||||
private readonly AxeptaTokenManager $tokenManager,
|
||||
private readonly AxeptaSignatureVerifier $signatureVerifier
|
||||
) {
|
||||
$this->axeptaCheckoutConfig = [
|
||||
...[
|
||||
"language" => "fr",
|
||||
"amount" => ["currency" => "EUR"],
|
||||
"captureMethod" => ["type" => "AUTOMATIC"],
|
||||
"allowedPaymentMethods" => ["CARD"],
|
||||
"paymentMethods" => [
|
||||
"card" => ["subType" => ["VISA", "MASTERCARD"]],
|
||||
]
|
||||
],
|
||||
...$axeptaConfig->checkoutConfig
|
||||
];
|
||||
}
|
||||
|
||||
public function getCheckoutUrl(
|
||||
#[\SensitiveParameter] AxeptaOrder $order,
|
||||
#[\SensitiveParameter] string $returnUrl,
|
||||
#[\SensitiveParameter] string $cancelUrl,
|
||||
#[\SensitiveParameter] string $webhookUrl
|
||||
): string {
|
||||
$payload = [
|
||||
"transId" => $this->generateTransId(),
|
||||
"urls" => ["return" => $returnUrl, "cancel" => $cancelUrl, "webhook" => $webhookUrl],
|
||||
"amount" => ["value" => $order->amount],
|
||||
"order" => ["merchantReference" => $order->reference, "creationDate" => gmdate('c')],
|
||||
"customerInfo" => ["email" => $order->customerEmail]
|
||||
];
|
||||
|
||||
try {
|
||||
$token = $this->tokenManager->getToken();
|
||||
$response = $this->gateway->post(
|
||||
'/api/v2/payments/sessions',
|
||||
array_merge_recursive($payload, $this->axeptaCheckoutConfig),
|
||||
$token
|
||||
);
|
||||
|
||||
if ($response->getStatusCode() !== 202) {
|
||||
throw new \RuntimeException('Session creation failed');
|
||||
}
|
||||
|
||||
$responseBody = json_decode($response->getBody()->getContents());
|
||||
|
||||
if (!isset($responseBody->_links->redirect->href)) {
|
||||
throw new \RuntimeException('Missing redirect URL in response');
|
||||
}
|
||||
|
||||
return $responseBody->_links->redirect->href;
|
||||
} catch (RequestException $e) {
|
||||
throw new \RuntimeException("Payment session error: " . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
public function isValidWebhookAccess(Request $request): bool
|
||||
{
|
||||
$signatureVersion = $this->getHeaderValue($request, 'x-paygate-signature-version');
|
||||
$signature = $this->getHeaderValue($request, 'x-paygate-signature');
|
||||
$timestamp = $this->getHeaderValue($request, 'x-paygate-timestamp');
|
||||
$rawPayload = $request->getContent(false);
|
||||
|
||||
$this->logger->debug('Received an IPN call', [
|
||||
'request_signature_version' => $signatureVersion,
|
||||
'request_signature' => $signature,
|
||||
'request_timestamp' => $timestamp,
|
||||
'request_payload' => base64_encode($rawPayload)
|
||||
]);
|
||||
|
||||
return $this->signatureVerifier->verify($signatureVersion, $signature, $timestamp, $rawPayload);
|
||||
}
|
||||
|
||||
public function getPaymentStatusById(string $paymentId): ?string
|
||||
{
|
||||
try {
|
||||
$token = $this->tokenManager->getToken();
|
||||
$response = $this->gateway->get(sprintf('/api/v2/payments/getByPayId/%s', $paymentId), $token);
|
||||
$responseBody = json_decode($response->getBody()->getContents());
|
||||
|
||||
return $responseBody->responseCode ?? null;
|
||||
} catch (RequestException $e) {
|
||||
if ($e->hasResponse()) {
|
||||
$responseBody = json_decode($e->getResponse()->getBody()->getContents());
|
||||
return $responseBody->responseCode ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateTransId(): string
|
||||
{
|
||||
return Str::uuid7()->toString();
|
||||
}
|
||||
|
||||
private function getHeaderValue(Request $request, string $headerName): string
|
||||
{
|
||||
$headerValue = Arr::wrap($request->header($headerName));
|
||||
$headerValue = Arr::map($headerValue, fn($v) => is_scalar($v) ? strval($v) : '');
|
||||
|
||||
return Str::trim(Arr::join($headerValue, ' '));
|
||||
}
|
||||
}
|
||||
103
app/Lib/Axepta/AxeptaSignatureVerifier.php
Normal file
103
app/Lib/Axepta/AxeptaSignatureVerifier.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Lib\Axepta;
|
||||
|
||||
use App\Lib\Axepta\AxeptaConfig;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AxeptaSignatureVerifier
|
||||
{
|
||||
private const int PERMITTED_TIMESTAMP_SKEW_IN_MINUTES = 5;
|
||||
private const string USING_API_VERSION = 'v1';
|
||||
|
||||
public function __construct(
|
||||
private readonly AxeptaConfig $config,
|
||||
private readonly LoggerInterface $logger
|
||||
) {}
|
||||
|
||||
public function verify(string $version, string $signature, string $timestamp, string $rawPayload): bool
|
||||
{
|
||||
$time = Carbon::createFromTimestampUTC($timestamp);
|
||||
|
||||
return $this->isValidSignatureVersion($version) &&
|
||||
$this->requestTimestampIsAcceptable($time) &&
|
||||
$this->isValidSignature($signature, $time, $rawPayload);
|
||||
}
|
||||
|
||||
private function isValidSignatureVersion(string $version): bool
|
||||
{
|
||||
if ($version !== self::USING_API_VERSION) {
|
||||
$this->logger->error("Version is not accepted", [
|
||||
'request_version' => $version,
|
||||
'accepted_version' => self::USING_API_VERSION
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function requestTimestampIsAcceptable(CarbonInterface $timestamp): bool
|
||||
{
|
||||
$calculatedDiff = Carbon::now('UTC')->diffInMinutes($timestamp, true);
|
||||
if ($calculatedDiff > self::PERMITTED_TIMESTAMP_SKEW_IN_MINUTES) {
|
||||
$this->logger->error("request timestamp skew is not matched", [
|
||||
'request_timestamp' => $timestamp,
|
||||
'diff' => $calculatedDiff,
|
||||
'max_skew_in_minutes' => self::PERMITTED_TIMESTAMP_SKEW_IN_MINUTES
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function isValidSignature(
|
||||
#[\SensitiveParameter] string $signature,
|
||||
CarbonInterface $timestamp,
|
||||
#[\SensitiveParameter] string $rawPayload
|
||||
): bool {
|
||||
$providedSignature = $this->extractSignatureFromCurrentVersion($signature);
|
||||
|
||||
if (!$providedSignature) {
|
||||
$this->logger->error("Could not extract signature from '{$signature}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
$expectedSignature = hash_hmac('sha256', "{$timestamp->timestamp}.{$rawPayload}", $this->config->hmac);
|
||||
$signatureIsValid = hash_equals(
|
||||
$expectedSignature,
|
||||
$providedSignature
|
||||
);
|
||||
|
||||
if (!$signatureIsValid) {
|
||||
$this->logger->debug("Could not validate expected signature", [
|
||||
'request_signature' => $signature,
|
||||
'expected' => $expectedSignature
|
||||
]);
|
||||
}
|
||||
|
||||
return $signatureIsValid;
|
||||
}
|
||||
|
||||
private function extractSignatureFromCurrentVersion(#[\SensitiveParameter] string $signature): ?string
|
||||
{
|
||||
$signatures = explode(',', $signature);
|
||||
|
||||
foreach ($signatures as $sig) {
|
||||
$parts = explode('=', trim($sig));
|
||||
|
||||
if (count($parts) === 2 && trim($parts[0]) === self::USING_API_VERSION) {
|
||||
$hex = trim($parts[1]);
|
||||
|
||||
if (strlen($hex) === 64) {
|
||||
return strtolower($hex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
52
app/Lib/Axepta/AxeptaTokenManager.php
Normal file
52
app/Lib/Axepta/AxeptaTokenManager.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Lib\Axepta;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
|
||||
class AxeptaTokenManager
|
||||
{
|
||||
private const string CACHE_KEY = "AxeptaBearerToken";
|
||||
|
||||
public function __construct(
|
||||
private readonly AxeptaApiGateway $gateway,
|
||||
private readonly AxeptaConfig $config
|
||||
) {}
|
||||
|
||||
public function getToken(): string
|
||||
{
|
||||
if (Cache::has(self::CACHE_KEY)) {
|
||||
return Cache::get(self::CACHE_KEY);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->gateway->postForm('/authorization/oauth/token', [
|
||||
'grant_type' => 'client_credentials',
|
||||
'client_id' => $this->config->merchantId,
|
||||
'client_secret' => $this->config->apiKey
|
||||
]);
|
||||
|
||||
if ($response->getStatusCode() !== 200) {
|
||||
throw new \RuntimeException('Token request failed');
|
||||
}
|
||||
|
||||
$body = json_decode($response->getBody()->getContents());
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || !isset($body->access_token) || !isset($body->expires_in)) {
|
||||
throw new \RuntimeException('Invalid token response');
|
||||
}
|
||||
|
||||
Cache::put(self::CACHE_KEY, $body->access_token, $body->expires_in);
|
||||
|
||||
return $body->access_token;
|
||||
} catch (RequestException $e) {
|
||||
$errorBody = $e->hasResponse() ? $e->getResponse()->getBody()->getContents() : '{}';
|
||||
$errorData = json_decode($errorBody, true);
|
||||
$message = $errorData['error'] ?? $errorData['responseDescription'] ?? $e->getMessage();
|
||||
throw new \RuntimeException("Axepta token error: {$message}", 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Lib\Axepta\Objects;
|
||||
|
||||
class AxeptaOrder
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
final readonly class AxeptaOrder
|
||||
{
|
||||
/**
|
||||
* Construct method
|
||||
|
|
@ -17,5 +21,17 @@ class AxeptaOrder
|
|||
public int $amount,
|
||||
public string $customerEmail
|
||||
) {
|
||||
if (empty(trim($reference))) {
|
||||
throw new \DomainException('Reference cannot be empty');
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
throw new \DomainException('Amount cannot be negative');
|
||||
}
|
||||
if (
|
||||
empty(trim($customerEmail)) ||
|
||||
Validator::make(['email' => $customerEmail], ['email' => 'email:rfc'])->fails()
|
||||
) {
|
||||
throw new \DomainException('A valid email is required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@
|
|||
namespace App\Providers;
|
||||
|
||||
use App\Enums\Settings\SettingsType;
|
||||
use App\Lib\Axepta\AxeptaPayment;
|
||||
use App\Lib\Axepta\AxeptaApiGateway;
|
||||
use App\Lib\Axepta\AxeptaConfig;
|
||||
use App\Lib\Axepta\AxeptaPaymentService;
|
||||
use App\Lib\Axepta\AxeptaTokenManager;
|
||||
use App\Lib\Axepta\AxeptaSignatureVerifier;
|
||||
use App\Models\GeneralSetting;
|
||||
use App\Models\MenuNodeData;
|
||||
use App\Providers\TelescopeServiceProvider as CustomTelescopeServiceProvider;
|
||||
|
|
@ -61,14 +65,25 @@ class AppServiceProvider extends ServiceProvider
|
|||
App::singleton('censor', fn() => new CensorWords());
|
||||
|
||||
// * Axepta
|
||||
$this->app->singleton(AxeptaPayment::class, function () {
|
||||
$this->app->singleton(AxeptaPaymentService::class, function () {
|
||||
$logger = resolve(LoggerInterface::class);
|
||||
return new AxeptaPayment(
|
||||
$config = new AxeptaConfig(
|
||||
endpoint: Config::string('axepta.endpoint'),
|
||||
merchantId: Config::string('axepta.merchantId'),
|
||||
apiKey: Config::string('axepta.apiKey'),
|
||||
hmac: Config::string('axepta.hmac')
|
||||
);
|
||||
|
||||
$gateway = new AxeptaApiGateway($config);
|
||||
$tokenManager = new AxeptaTokenManager($gateway, $config);
|
||||
$signatureVerifier = new AxeptaSignatureVerifier($config, $logger);
|
||||
|
||||
return new AxeptaPaymentService(
|
||||
$logger,
|
||||
Config::string('axepta.endpoint'),
|
||||
Config::string('axepta.merchantId'),
|
||||
Config::string('axepta.apiKey'),
|
||||
Config::string('axepta.hmac')
|
||||
$config,
|
||||
$gateway,
|
||||
$tokenManager,
|
||||
$signatureVerifier
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
59
phpunit.xml
59
phpunit.xml
|
|
@ -1,32 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd" bootstrap="vendor/autoload.php" colors="true">
|
||||
<coverage includeUncoveredFiles="true">
|
||||
<report>
|
||||
<html outputDirectory="tests/coverage" />
|
||||
</report>
|
||||
</coverage>
|
||||
<testsuites>
|
||||
<testsuite name="Tests">
|
||||
<directory suffix="Test.php">
|
||||
<coverage includeUncoveredFiles="true">
|
||||
<report>
|
||||
<html outputDirectory="tests/coverage" />
|
||||
</report>
|
||||
</coverage>
|
||||
<testsuites>
|
||||
<testsuite name="Tests">
|
||||
<directory suffix="Test.php">
|
||||
./tests/Back
|
||||
</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing" />
|
||||
<env name="BCRYPT_ROUNDS" value="4" />
|
||||
<env name="CACHE_DRIVER" value="array" />
|
||||
<env name="MAIL_MAILER" value="array" />
|
||||
<env name="QUEUE_DRIVER" value="database" />
|
||||
<env name="QUEUE_CONNECTION" value="database" />
|
||||
<env name="SESSION_DRIVER" value="array" />
|
||||
<env name="TELESCOPE_ENABLED" value="false" />
|
||||
</php>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">
|
||||
</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Unit">
|
||||
<directory suffix="Test.php">
|
||||
./tests/Unit
|
||||
</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing" />
|
||||
<env name="BCRYPT_ROUNDS" value="4" />
|
||||
<env name="CACHE_DRIVER" value="array" />
|
||||
<env name="MAIL_MAILER" value="array" />
|
||||
<env name="QUEUE_DRIVER" value="database" />
|
||||
<env name="QUEUE_CONNECTION" value="database" />
|
||||
<env name="SESSION_DRIVER" value="array" />
|
||||
<env name="TELESCOPE_ENABLED" value="false" />
|
||||
</php>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">
|
||||
./app
|
||||
</directory>
|
||||
</include>
|
||||
</source>
|
||||
</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
|
|
|
|||
|
|
@ -217,10 +217,6 @@
|
|||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- A supprimer à la mise en place de la CB -->
|
||||
<p class="fst-italic m-0">
|
||||
{{ trans.methods.__('(CB prochainement)') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- FORM RGPD -->
|
||||
|
|
|
|||
116
tests/Unit/Lib/Axepta/AxeptaConfigTest.php
Normal file
116
tests/Unit/Lib/Axepta/AxeptaConfigTest.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Lib\Axepta;
|
||||
|
||||
use App\Lib\Axepta\AxeptaConfig;
|
||||
use DomainException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AxeptaConfigTest extends TestCase
|
||||
{
|
||||
public function testCanInstanciateAnAxeptaConfig(): void
|
||||
{
|
||||
// GIVEN
|
||||
$endpoint = 'https://api.axepta.com';
|
||||
$merchantId = 'M_123456';
|
||||
$apiKey = 'secret_key_123';
|
||||
$hmac = 'hmac_value_456';
|
||||
|
||||
// WHEN
|
||||
$config = new AxeptaConfig(
|
||||
$endpoint,
|
||||
$merchantId,
|
||||
$apiKey,
|
||||
$hmac
|
||||
);
|
||||
|
||||
// THEN
|
||||
$this->assertInstanceOf(AxeptaConfig::class, $config);
|
||||
}
|
||||
|
||||
public function testCannotInstanciateWithEmptyEndpoint(): void
|
||||
{
|
||||
$this->assertThrows(function () {
|
||||
// GIVEN
|
||||
$endpoint = '';
|
||||
$merchantId = 'M_123';
|
||||
$apiKey = 'key';
|
||||
$hmac = 'hmac';
|
||||
|
||||
// WHEN
|
||||
new AxeptaConfig(
|
||||
$endpoint,
|
||||
$merchantId,
|
||||
$apiKey,
|
||||
$hmac
|
||||
);
|
||||
|
||||
// THEN
|
||||
}, DomainException::class);
|
||||
}
|
||||
|
||||
public function testCannotInstanciateWithEmptyMerchantId(): void
|
||||
{
|
||||
$this->assertThrows(function () {
|
||||
// GIVEN
|
||||
$endpoint = 'https://api.axepta.com';
|
||||
$merchantId = '';
|
||||
$apiKey = 'key';
|
||||
$hmac = 'hmac';
|
||||
|
||||
// WHEN
|
||||
new AxeptaConfig(
|
||||
$endpoint,
|
||||
$merchantId,
|
||||
$apiKey,
|
||||
$hmac
|
||||
);
|
||||
|
||||
// THEN
|
||||
}, DomainException::class);
|
||||
}
|
||||
|
||||
public function testCannotInstanciateWithEmptyApiKey(): void
|
||||
{
|
||||
$this->assertThrows(function () {
|
||||
// GIVEN
|
||||
$endpoint = 'https://api.axepta.com';
|
||||
$merchantId = 'M_123';
|
||||
$apiKey = '';
|
||||
$hmac = 'hmac';
|
||||
|
||||
// WHEN
|
||||
new AxeptaConfig(
|
||||
$endpoint,
|
||||
$merchantId,
|
||||
$apiKey,
|
||||
$hmac
|
||||
);
|
||||
|
||||
// THEN
|
||||
}, DomainException::class);
|
||||
}
|
||||
|
||||
public function testCannotInstanciateWithEmptyHmac(): void
|
||||
{
|
||||
$this->assertThrows(function () {
|
||||
// GIVEN
|
||||
$endpoint = 'https://api.axepta.com';
|
||||
$merchantId = 'M_123';
|
||||
$apiKey = 'key';
|
||||
$hmac = '';
|
||||
|
||||
// WHEN
|
||||
new AxeptaConfig(
|
||||
$endpoint,
|
||||
$merchantId,
|
||||
$apiKey,
|
||||
$hmac
|
||||
);
|
||||
|
||||
// THEN
|
||||
}, DomainException::class);
|
||||
}
|
||||
}
|
||||
87
tests/Unit/Lib/Axepta/AxeptaOrderTest.php
Normal file
87
tests/Unit/Lib/Axepta/AxeptaOrderTest.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Lib\Axepta;
|
||||
|
||||
use App\Lib\Axepta\Objects\AxeptaOrder;
|
||||
use DomainException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AxeptaOrderTest extends TestCase
|
||||
{
|
||||
public function testCanInstanciateAnAxeptaOrder(): void
|
||||
{
|
||||
// GIVEN
|
||||
$reference = 'IRDtm95jvhCN';
|
||||
$amount = 200;
|
||||
$email = 'valid-email@example.org';
|
||||
|
||||
// WHEN
|
||||
$order = new AxeptaOrder(
|
||||
$reference,
|
||||
$amount,
|
||||
$email,
|
||||
);
|
||||
|
||||
// THEN
|
||||
$this->assertInstanceOf(AxeptaOrder::class, $order);
|
||||
}
|
||||
|
||||
public function testCannotInstanciateAnAxeptaOrderWithEmptyReference(): void
|
||||
{
|
||||
$this->assertThrows(function () {
|
||||
// GIVEN
|
||||
$reference = '';
|
||||
$amount = 200;
|
||||
$email = 'valid-email@example.org';
|
||||
|
||||
// WHEN
|
||||
new AxeptaOrder(
|
||||
$reference,
|
||||
$amount,
|
||||
$email,
|
||||
);
|
||||
|
||||
// THEN
|
||||
}, DomainException::class);
|
||||
}
|
||||
|
||||
public function testCannotInstanciateAnAxeptaOrderWithNonPositiveAmount(): void
|
||||
{
|
||||
$this->assertThrows(function () {
|
||||
// GIVEN
|
||||
$reference = 'IRDtm95jvhCN';
|
||||
$amount = 0;
|
||||
$email = 'valid-email@example.org';
|
||||
|
||||
// WHEN
|
||||
new AxeptaOrder(
|
||||
$reference,
|
||||
$amount,
|
||||
$email,
|
||||
);
|
||||
|
||||
// THEN
|
||||
}, DomainException::class);
|
||||
}
|
||||
|
||||
public function testCannotInstanciateAnAxeptaOrderWithInvalidEmail(): void
|
||||
{
|
||||
$this->assertThrows(function () {
|
||||
// GIVEN
|
||||
$reference = 'IRDtm95jvhCN';
|
||||
$amount = 200;
|
||||
$email = 'invalid-email';
|
||||
|
||||
// WHEN
|
||||
new AxeptaOrder(
|
||||
$reference,
|
||||
$amount,
|
||||
$email,
|
||||
);
|
||||
|
||||
// THEN
|
||||
}, DomainException::class);
|
||||
}
|
||||
}
|
||||
75
tests/Unit/Lib/Axepta/AxeptaPaymentServiceTest.php
Normal file
75
tests/Unit/Lib/Axepta/AxeptaPaymentServiceTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Lib\Axepta;
|
||||
|
||||
use App\Lib\Axepta\AxeptaApiGateway;
|
||||
use App\Lib\Axepta\AxeptaConfig;
|
||||
use App\Lib\Axepta\AxeptaPaymentService;
|
||||
use App\Lib\Axepta\AxeptaTokenManager;
|
||||
use App\Lib\Axepta\AxeptaSignatureVerifier;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Http\Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AxeptaPaymentServiceTest extends TestCase
|
||||
{
|
||||
private LoggerInterface $logger;
|
||||
private AxeptaConfig $config;
|
||||
private AxeptaApiGateway $gateway;
|
||||
private AxeptaTokenManager $tokenManager;
|
||||
private AxeptaSignatureVerifier $verifier;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->config = new AxeptaConfig('url', 'm_id', 'key', 'hmac', []);
|
||||
$this->gateway = $this->createMock(AxeptaApiGateway::class);
|
||||
$this->tokenManager = $this->createMock(AxeptaTokenManager::class);
|
||||
$this->verifier = $this->createMock(AxeptaSignatureVerifier::class);
|
||||
}
|
||||
|
||||
public function testCanGetPaymentStatusById(): void
|
||||
{
|
||||
// GIVEN
|
||||
$service = new AxeptaPaymentService($this->logger, $this->config, $this->gateway, $this->tokenManager, $this->verifier);
|
||||
$this->tokenManager->method('getToken')->willReturn('fake-token');
|
||||
|
||||
$jsonResponse = json_encode(['responseCode' => 'SUCCESS']);
|
||||
$this->gateway->expects($this->once())
|
||||
->method('get')
|
||||
->willReturn(new Response(200, [], $jsonResponse));
|
||||
|
||||
// WHEN
|
||||
$status = $service->getPaymentStatusById('PAY_123');
|
||||
|
||||
// THEN
|
||||
$this->assertEquals('SUCCESS', $status);
|
||||
}
|
||||
|
||||
public function testCanValidateWebhookAccess(): void
|
||||
{
|
||||
// GIVEN
|
||||
$service = new AxeptaPaymentService($this->logger, $this->config, $this->gateway, $this->tokenManager, $this->verifier);
|
||||
|
||||
$request = Request::create('/webhook', 'POST', [], [], [], [
|
||||
'HTTP_X_PAYGATE_SIGNATURE_VERSION' => 'v1',
|
||||
'HTTP_X_PAYGATE_SIGNATURE' => 'v1=abc123hex',
|
||||
'HTTP_X_PAYGATE_TIMESTAMP' => '1600000000'
|
||||
], '{"payload":true}');
|
||||
|
||||
$this->verifier->expects($this->once())
|
||||
->method('verify')
|
||||
->with('v1', 'v1=abc123hex', '1600000000', '{"payload":true}')
|
||||
->willReturn(true);
|
||||
|
||||
// WHEN
|
||||
$isValid = $service->isValidWebhookAccess($request);
|
||||
|
||||
// THEN
|
||||
$this->assertTrue($isValid);
|
||||
}
|
||||
}
|
||||
78
tests/Unit/Lib/Axepta/AxeptaSignatureVerifierTest.php
Normal file
78
tests/Unit/Lib/Axepta/AxeptaSignatureVerifierTest.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Lib\Axepta\Security;
|
||||
|
||||
use App\Lib\Axepta\AxeptaConfig;
|
||||
use App\Lib\Axepta\AxeptaSignatureVerifier;
|
||||
use Carbon\Carbon;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AxeptaSignatureVerifierTest extends TestCase
|
||||
{
|
||||
public function testCanVerifyRealAxeptaSignatureFromBase64Payload(): void
|
||||
{
|
||||
// GIVEN
|
||||
$secret = '6Gp_A!8f)3zZ9K]gb7S?2*tN(yH54[wB';
|
||||
$config = new AxeptaConfig('url', 'm_id', 'key', $secret, []);
|
||||
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$verifier = new AxeptaSignatureVerifier($config, $logger);
|
||||
|
||||
$timestamp = '1782229303';
|
||||
$signatureHeader = 'v1=3E954907AB5C373D2D98AD3F702CD63819B9735C1C98D23058050FEDE6FA6844';
|
||||
|
||||
$base64Payload = 'eyJtZXJjaGFudElkIjogIkJOUF9ERU1PX01JRCIsInBheUlkIjogImEyZTZiMTg5NmUwMzRkNDhhYzU0MDhjYjAzZmE2OWY0IiwidHJhbnNJZCI6ICJRN31DbEouVm8lR1ZBY3dOR0wlKWZQKylJdSVoNX1mNik4fCNnQ2dBRUoyNFF2RjZfWSNeaUo6diZsdDt0PVBBY2Y9IiwieGlkIjogImMyZGM1MzliMjVkNzRhYjRhYzk3NzA0MmE3MjkyNWQxIiwicmVmTnIiOiAiNmpveWEwR1JtMnAyIiwic3RhdHVzIjogIkZBSUxFRCIsInJlc3BvbnNlQ29kZSI6ICIyMDE0MDAwMiIsInJlc3BvbnNlRGVzY3JpcHRpb24iOiAiUGFyYW0gdG9vIGxvbmc6IHRyYW5zaWQiLCJhbW91bnQiOiB7InZhbHVlIjogNDAwLCJjdXJyZW5jeSI6ICJFVVIifSwicGF5bWVudE1ldGhvZHMiOiB7InR5cGUiOiAiQ0FSRCJ9LCJjcmVhdGlvbkRhdGUiOiAiMjAyNi0wNi0yM1QxNTo0MTo0M1oiLCJjaGFubmVsIjogIkVDT00ifQ==';
|
||||
$rawPayload = base64_decode($base64Payload);
|
||||
|
||||
Carbon::setTestNow(Carbon::createFromTimestampUTC((int) $timestamp));
|
||||
|
||||
// WHEN
|
||||
$isValid = $verifier->verify('v1', $signatureHeader, $timestamp, $rawPayload);
|
||||
|
||||
// THEN
|
||||
$this->assertTrue($isValid);
|
||||
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
|
||||
public function testCannotVerifyWithExpiredTimestamp(): void
|
||||
{
|
||||
// GIVEN
|
||||
$secret = '6Gp_A!8f)3zZ9K]gb7S?2*tN(yH54[wB';
|
||||
$config = new AxeptaConfig('url', 'm_id', 'key', $secret, []);
|
||||
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$verifier = new AxeptaSignatureVerifier($config, $logger);
|
||||
|
||||
// NOTE: 10 Minutes later
|
||||
Carbon::setTestNow(Carbon::createFromTimestampUTC(1782222184 + 600));
|
||||
$timestamp = '1782222184';
|
||||
$payload = '{"status":"ok"}';
|
||||
|
||||
$validHash = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
|
||||
|
||||
// WHEN
|
||||
$isValid = $verifier->verify('v1', "v1={$validHash}", $timestamp, $payload);
|
||||
|
||||
// THEN
|
||||
$this->assertFalse($isValid);
|
||||
|
||||
Carbon::setTestNow();
|
||||
}
|
||||
|
||||
public function testCannotVerifyInvalidVersion(): void
|
||||
{
|
||||
// GIVEN
|
||||
$config = new AxeptaConfig('url', 'm_id', 'key', 'secret', []);
|
||||
$verifier = new AxeptaSignatureVerifier($config, $this->createMock(LoggerInterface::class));
|
||||
|
||||
// WHEN
|
||||
$isValid = $verifier->verify('v2', 'v1=hash', '123', '{}');
|
||||
|
||||
// THEN
|
||||
$this->assertFalse($isValid);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue