From caa3991efb90c7f1f1691ee96a4084cf85057208 Mon Sep 17 00:00:00 2001 From: bajoski34 Date: Thu, 17 Sep 2026 12:51:17 +0100 Subject: [PATCH 1/2] harden expose routes and webhook handler --- Controller/Payment/Callback.php | 83 +++++++++------ Controller/Payment/Webhook.php | 181 ++++++++++++-------------------- Model/Payment.php | 52 +++++++-- README.md | 3 +- etc/adminhtml/system.xml | 6 ++ 5 files changed, 169 insertions(+), 156 deletions(-) diff --git a/Controller/Payment/Callback.php b/Controller/Payment/Callback.php index f37ff75..8b3a723 100644 --- a/Controller/Payment/Callback.php +++ b/Controller/Payment/Callback.php @@ -71,29 +71,58 @@ public function execute(): ResultInterface $data = $this->request->getParams(); $this->logger->info('Flutterwave Redirect Callback Data: ' . json_encode($data)); - $reference = $data['client_reference'] ?? null; - $status = $data['status'] ?? null; - $this->signozLogger->trackRequestSent('callback', (string) ($reference ?? 'unknown'), '/flutterwave/payment/callback', ['status' => $status ?? 'unknown']); + $clientReference = isset($data['client_reference']) ? (string) $data['client_reference'] : ''; + $transactionId = isset($data['transaction_id']) ? (string) $data['transaction_id'] : ''; + $this->signozLogger->trackRequestSent('callback', $clientReference !== '' ? $clientReference : 'unknown', '/flutterwave/payment/callback', ['status' => (string) ($data['status'] ?? 'unknown')]); $redirect = $this->resultRedirectFactory->create(); - if (!$reference || !$status) { - $this->logger->error('Flutterwave Redirect: Missing reference or status.'); - $this->signozLogger->trackError('callback.invalid', 'Missing reference or status.', '', null, ['payload' => $data]); + // Query parameters are attacker-controlled, so without a verifiable transaction nothing is changed. + // Cancelled or abandoned payments leave the order pending so the customer can retry. + if ($transactionId === '') { + $this->messageManager->addErrorMessage(__('Your payment was not completed. Please try again.')); + return $redirect->setPath('checkout/cart'); } try { - $order = $this->orderFactory->create()->loadByIncrementId($reference); + $verification = $this->payment->verifyTransaction($transactionId); + $this->logger->info('Flutterwave Transaction Verification: ' . json_encode($verification)); + + if (empty($verification)) { + throw new \Exception("Transaction {$transactionId} could not be verified."); + } + + $reference = $verification['tx_ref']; + $orderIncrementId = $this->payment->getOrderIncrementIdFromTxRef($reference); + + if ($orderIncrementId === null || ($clientReference !== '' && $clientReference !== $orderIncrementId)) { + throw new \Exception("Verified transaction {$transactionId} does not belong to order {$clientReference}."); + } + + $order = $this->orderFactory->create()->loadByIncrementId($orderIncrementId); if (!$order || !$order->getId()) { throw new \Exception("Order not found for reference: $reference"); } - $verification = $this->payment->verifyTransaction($data['tx_ref']); - $this->logger->info('Flutterwave Transaction Verification: ' . json_encode($verification)); + if (in_array($order->getState(), [Order::STATE_PROCESSING, Order::STATE_COMPLETE], true)) { + return $redirect->setPath('checkout/onepage/success'); + } + + if (!in_array($order->getState(), [Order::STATE_NEW, Order::STATE_PENDING_PAYMENT], true)) { + return $redirect->setPath('checkout/cart'); + } + + if ($verification['status'] !== Payment::STATUS_SUCCESSFUL) { + $this->messageManager->addErrorMessage(__('Your payment could not be verified. Please try again.')); + return $redirect->setPath('checkout/cart'); + } + + $order->getPayment() + ->setLastTransId($verification['id']) + ->setAdditionalInformation('flutterwave_transaction_id', $verification['id']) + ->setAdditionalInformation('flutterwave_tx_ref', $reference); if ( - $status === 'success' && - $verification['status'] === 'success' && $this->amounts_equal($order->getGrandTotal(), $verification['amount']) && $order->getOrderCurrencyCode() === $verification['currency'] ) { @@ -103,7 +132,7 @@ public function execute(): ResultInterface (float) $verification['amount'], 'callback', 0.0, - ['order_id' => (string) $order->getIncrementId(), 'status' => $status] + ['order_id' => (string) $order->getIncrementId(), 'status' => $verification['status']] ); $order->setState(Order::STATE_PROCESSING) @@ -111,31 +140,19 @@ public function execute(): ResultInterface $order->save(); return $redirect->setPath('checkout/onepage/success'); - } elseif ($status === 'cancelled' && - $verification['status'] === 'pending' - ){ - $order->setState(Order::STATE_CANCELED) - ->setStatus(Order::STATE_CANCELED); - $order->save(); - return $redirect->setPath('checkout/cart'); - } elseif( !$this->amounts_equal($order->getGrandTotal(), $verification['amount'] ) || $order->getOrderCurrencyCode() !== $verification['currency'] ){ - $order->setState(Order::STATE_HOLDED) - ->setStatus(Order::STATE_HOLDED) - ->addCommentToStatusHistory(__('Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it. Amount Paid: '. $verification['currency'] .$verification['amount'])); - return $redirect->setPath('checkout/cart'); - } else { - $order->setState(Order::STATE_CANCELED) - ->setStatus(Order::STATE_CANCELED) - ->addCommentToStatusHistory(__('Payment Status could not be verified.')); - $order->save(); - - $this->messageManager->addErrorMessage(__('Your payment could not be verified. Please try again.')); - return $redirect->setPath('checkout/cart'); } + $order->setState(Order::STATE_HOLDED) + ->setStatus(Order::STATE_HOLDED) + ->addCommentToStatusHistory(__('Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it. Amount Paid: '. $verification['currency'] .$verification['amount'])); + $order->save(); + + $this->messageManager->addErrorMessage(__('Your payment amount did not match the order total. Please contact support.')); + return $redirect->setPath('checkout/cart'); + } catch (\Exception $e) { $this->logger->error('Flutterwave Redirect Error: ' . $e->getMessage()); - $this->signozLogger->trackError('callback.exception', $e->getMessage(), (string) ($reference ?? ''), $e->getTraceAsString(), ['source' => 'callback']); + $this->signozLogger->trackError('callback.exception', $e->getMessage(), $clientReference, $e->getTraceAsString(), ['source' => 'callback']); $this->messageManager->addErrorMessage(__('A server error occurred. Please contact support.')); return $redirect->setPath('checkout/cart'); } diff --git a/Controller/Payment/Webhook.php b/Controller/Payment/Webhook.php index 59259df..a09c8e9 100644 --- a/Controller/Payment/Webhook.php +++ b/Controller/Payment/Webhook.php @@ -5,10 +5,9 @@ use Magento\Framework\App\CsrfAwareActionInterface; use Magento\Framework\App\Request\InvalidRequestException; use Magento\Framework\App\RequestInterface; +use Magento\Framework\App\Request\Http as HttpRequest; use Flutterwave\Payment\Model\Payment; use Magento\Sales\Model\Order; -use Magento\Framework\Message\ManagerInterface; -use Magento\Framework\Controller\ResultFactory; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Sales\Model\OrderFactory; use Psr\Log\LoggerInterface; @@ -20,14 +19,14 @@ class Webhook implements HttpPostActionInterface, CsrfAwareActionInterface private JsonFactory $jsonFactory; private OrderFactory $orderFactory; private $payment; - private $messageManager; + private HttpRequest $request; private FlutterwaveSignozLogger $signozLogger; public function __construct( LoggerInterface $logger, Payment $payment, JsonFactory $jsonFactory, - ManagerInterface $messageManager, + HttpRequest $request, OrderFactory $orderFactory, FlutterwaveSignozLogger $signozLogger ) { @@ -35,41 +34,10 @@ public function __construct( $this->payment = $payment; $this->jsonFactory = $jsonFactory; $this->orderFactory = $orderFactory; - $this->messageManager = $messageManager; + $this->request = $request; $this->signozLogger = $signozLogger; } - /** - * Get the Ip of the current request. - * - * @return string - */ - public function getFlutterwaveClientIp() { - $ip_keys = array( - 'HTTP_CLIENT_IP', - 'HTTP_X_FORWARDED_FOR', - 'HTTP_X_FORWARDED', - 'HTTP_X_CLUSTER_CLIENT_IP', - 'HTTP_FORWARDED_FOR', - 'HTTP_FORWARDED', - 'REMOTE_ADDR', - ); - - foreach ( $ip_keys as $key ) { - if ( ! empty( $_SERVER[ $key ] ) ) { - $ip_list = explode( ',', $_SERVER[ $key ] ); - foreach ( $ip_list as $ip ) { - $ip = trim( $ip ); - if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { - return $ip; - } - } - } - } - - return 'UNKNOWN'; - } - /** * Check Amount Equals. * @@ -93,107 +61,94 @@ public function execute() $resultJson = $this->jsonFactory->create(); try { + $secretHash = $this->payment->getSecretHash(); + $signature = (string) $this->request->getHeader('verif-hash'); + + if ($secretHash === '' || $signature === '' || !hash_equals($secretHash, $signature)) { + $this->logger->warning('Flutterwave Webhook rejected: invalid or missing verif-hash.'); + $this->signozLogger->trackError('webhook.unauthorized', 'Invalid or missing verif-hash header.', '', null, ['configured' => $secretHash !== '']); + return $resultJson->setData(['success' => false, 'message' => 'Unauthorized'])->setHttpResponseCode(401); + } - // if ( '52.3.180.49' !== $this->getFlutterwaveClientIp() ) { - // $this->logger->info( 'Faudulent Webhook Notification Attempt [Access Restricted]: ' . (string) $this->getFlutterwaveClientIp() ); - // return $resultJson->setData( - // array( - // 'status' => 'error', - // 'message' => 'Unauthorized Access (Restriction)', - // ) - // )->setHttpResponseCode(401); - // } - - $payload = file_get_contents('php://input'); + $payload = (string) $this->request->getContent(); $this->logger->info('Flutterwave Webhook Payload: ' . $payload); $data = json_decode($payload, true); + $transactionId = isset($data['data']['id']) ? (string) $data['data']['id'] : ''; + + if ($transactionId === '' || !ctype_digit($transactionId)) { + $this->signozLogger->trackError('webhook.invalid_payload', 'Invalid webhook payload: Missing transaction id.', '', null, ['payload' => $payload]); + return $resultJson->setData(['success' => false, 'message' => 'Invalid webhook payload: Missing transaction id.'])->setHttpResponseCode(400); + } - if (empty($data['data']['reference'])) { - $this->signozLogger->trackError('webhook.invalid_payload', 'Invalid webhook payload: Missing reference.', '', null, ['payload' => $payload]); - throw new \Exception('Invalid webhook payload: Missing reference.'); + // Only the verified transaction is trusted; the payload status and tx_ref are ignored. + $verification = $this->payment->verifyTransaction($transactionId); + $this->logger->info('Flutterwave Transaction Verification: ' . json_encode($verification)); + + if (empty($verification)) { + $this->signozLogger->trackError('webhook.verify_failed', 'Transaction could not be verified.', $transactionId, null, []); + return $resultJson->setData(['success' => false, 'message' => 'Transaction could not be verified.'])->setHttpResponseCode(502); } - $reference = $data['data']['reference']; - $status = $data['data']['status']; - $this->signozLogger->trackRequestSent('webhook', $reference, '/flutterwave/payment/webhook', ['status' => $status]); - $parts = explode('_', $reference); + $reference = $verification['tx_ref']; + $this->signozLogger->trackRequestSent('webhook', $reference, '/flutterwave/payment/webhook', ['status' => $verification['status']]); - if (count($parts) < 2 || !is_numeric($parts[1])) { - throw new \Exception('Invalid reference format: ' . $reference); + $orderIncrementId = $this->payment->getOrderIncrementIdFromTxRef($reference); + if ($orderIncrementId === null) { + // Not a transaction created by this module; acknowledge so Flutterwave stops retrying. + return $resultJson->setData(['success' => true, 'message' => 'Transaction not handled by this store.'])->setHttpResponseCode(200); } - $orderIncrementId = $parts[1]; $order = $this->orderFactory->create()->loadByIncrementId($orderIncrementId); if (!$order || !$order->getId()) { throw new \Exception("Order with Increment ID {$orderIncrementId} not found."); } - if(in_array($order->getState(), [ Order::STATE_PROCESSING, Order::STATE_COMPLETE ])) { - return $resultJson->setData(['success' => true, 'message' => 'Order Already Processed Successfully'])->setHttpResponseCode(400); + if (!in_array($order->getState(), [Order::STATE_NEW, Order::STATE_PENDING_PAYMENT], true)) { + return $resultJson->setData(['success' => true, 'message' => 'Order Already Processed'])->setHttpResponseCode(200); + } + + if ($verification['status'] !== Payment::STATUS_SUCCESSFUL) { + // Failed or pending attempts leave the order open so the customer can retry. + return $resultJson->setData(['success' => true, 'message' => 'Payment not successful; order left unchanged.'])->setHttpResponseCode(200); } - //TODO: get the current order status and make sure it is not completed. if so return a response stating it has been processed. - try { - $verification = $this->payment->verifyTransaction($data['reference']); - $this->logger->info('Flutterwave Transaction Verification: ' . json_encode($verification)); - - if ( - $status === 'success' && - $verification['status'] === 'success' && - $this->amounts_equal($order->getGrandTotal(), $verification['amount']) && - $order->getOrderCurrencyCode() === $verification['currency'] - ) { - $this->signozLogger->trackTransaction( - $reference, - (string) $order->getOrderCurrencyCode(), - (float) $verification['amount'], - 'webhook', - 0.0, - ['order_id' => (string) $order->getIncrementId(), 'status' => $status] - ); - - $order->setState(Order::STATE_PROCESSING) - ->setStatus(Order::STATE_PROCESSING); - $order->save(); - - return $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(201); - } elseif ($status === 'cancelled' && - $verification['status'] === 'pending' - ){ - $order->setState(Order::STATE_CANCELED) - ->setStatus(Order::STATE_CANCELED); - $order->save(); - return $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(201); - } elseif( !$this->amounts_equal($order->getGrandTotal(), $verification['amount'] ) || $order->getOrderCurrencyCode() !== $verification['currency'] ){ - $order->setState(Order::STATE_HOLDED) - ->setStatus(Order::STATE_HOLDED) - ->addCommentToStatusHistory(__('Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it. Amount Paid: '. $verification['currency'] .$verification['amount'])); - return $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(201); - } else { - $order->setState(Order::STATE_CANCELED) - ->setStatus(Order::STATE_CANCELED) - ->addCommentToStatusHistory(__('Payment Status could not be verified. Please try resending the webhook for this transaction.')); - $order->save(); - - $this->messageManager->addErrorMessage(__('Your payment could not be verified. Please try again.')); - $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(201); - } - - } catch (\Exception $e) { - $this->logger->error('Flutterwave Redirect Error: ' . $e->getMessage()); - $this->signozLogger->trackError('webhook.verify_failed', $e->getMessage(), $reference ?? '', $e->getTraceAsString(), ['status' => $status ?? 'unknown']); - $this->messageManager->addErrorMessage(__('A server error occurred. Please contact support.')); - return $resultJson->setData(['success' => false, 'message' => $e->getMessage()])->setHttpResponseCode(500); + $order->getPayment() + ->setLastTransId($verification['id']) + ->setAdditionalInformation('flutterwave_transaction_id', $verification['id']) + ->setAdditionalInformation('flutterwave_tx_ref', $reference); + + if ( + $this->amounts_equal($order->getGrandTotal(), $verification['amount']) && + $order->getOrderCurrencyCode() === $verification['currency'] + ) { + $this->signozLogger->trackTransaction( + $reference, + (string) $order->getOrderCurrencyCode(), + (float) $verification['amount'], + 'webhook', + 0.0, + ['order_id' => (string) $order->getIncrementId(), 'status' => $verification['status']] + ); + + $order->setState(Order::STATE_PROCESSING) + ->setStatus(Order::STATE_PROCESSING); + $order->save(); + + return $resultJson->setData(['success' => true, 'message' => 'Order Processed Successfully'])->setHttpResponseCode(200); } + $order->setState(Order::STATE_HOLDED) + ->setStatus(Order::STATE_HOLDED) + ->addCommentToStatusHistory(__('Attention: New order has been placed on hold because of incorrect payment amount or currency. Please, look into it. Amount Paid: '. $verification['currency'] .$verification['amount'])); + $order->save(); - return $resultJson->setData(['success' => true, 'message' => 'Webhook for Order' . $orderIncrementId . ' received and processed'])->setHttpResponseCode(201); + return $resultJson->setData(['success' => true, 'message' => 'Order placed on hold'])->setHttpResponseCode(200); } catch (\Exception $e) { $this->logger->critical('Flutterwave Webhook Error: ' . $e->getMessage()); $this->signozLogger->trackError('webhook.exception', $e->getMessage(), '', $e->getTraceAsString(), ['source' => 'webhook']); - return $resultJson->setData(['success' => false, 'message' => $e->getMessage()])->setHttpResponseCode(500); + return $resultJson->setData(['success' => false, 'message' => 'Webhook processing failed'])->setHttpResponseCode(500); } } diff --git a/Model/Payment.php b/Model/Payment.php index 04648b8..d71ecdb 100644 --- a/Model/Payment.php +++ b/Model/Payment.php @@ -7,10 +7,14 @@ use Magento\Framework\HTTP\Client\Curl; use Magento\Framework\UrlInterface; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\Encryption\EncryptorInterface; use Magento\Store\Model\ScopeInterface; class Payment extends AbstractMethod { + public const TX_REF_PREFIX = 'MAG'; + public const STATUS_SUCCESSFUL = 'successful'; + protected $_code = 'flutterwave'; protected $_isOffline = false; protected $checkoutSession; @@ -18,19 +22,40 @@ class Payment extends AbstractMethod protected $curl; protected $urlBuilder; protected $scopeConfig; + protected $encryptor; public function __construct( Session $checkoutSession, Order $order, Curl $curl, UrlInterface $urlBuilder, - ScopeConfigInterface $scopeConfig + ScopeConfigInterface $scopeConfig, + EncryptorInterface $encryptor ) { $this->checkoutSession = $checkoutSession; $this->order = $order; $this->curl = $curl; $this->urlBuilder = $urlBuilder; $this->scopeConfig = $scopeConfig; + $this->encryptor = $encryptor; + } + + public function getSecretHash(): string + { + $value = (string) $this->scopeConfig->getValue('payment/flutterwave/secret_hash', ScopeInterface::SCOPE_STORE); + return $value === '' ? '' : (string) $this->encryptor->decrypt($value); + } + + /** + * Extract the order increment id from a tx_ref built by startTransaction (MAG__). + */ + public function getOrderIncrementIdFromTxRef(string $txRef): ?string + { + $parts = explode('_', $txRef); + if (count($parts) !== 3 || $parts[0] !== self::TX_REF_PREFIX || !ctype_digit($parts[1])) { + return null; + } + return $parts[1]; } public function startTransaction() @@ -46,10 +71,10 @@ public function startTransaction() $requestData = [ 'amount' => $amount, - 'email' => $email, 'currency' => $currency, - 'reference' => "MAG_".$order->getIncrementId()."_". uniqid('old'), - 'callback' => $callbackUrl + 'tx_ref' => self::TX_REF_PREFIX . "_" . $orderId . "_" . uniqid('old'), + 'redirect_url' => $callbackUrl, + 'customer' => ['email' => $email] ]; $this->curl->setHeaders([ @@ -66,7 +91,14 @@ public function startTransaction() return false; } - public function verifyTransaction(string $reference) { + /** + * Verify a transaction by its Flutterwave transaction id (numeric), not by tx_ref. + */ + public function verifyTransaction(string $transactionId) { + if (!ctype_digit($transactionId)) { + return []; + } + $apiKey = $this->scopeConfig->getValue('payment/flutterwave/api_key', ScopeInterface::SCOPE_STORE); $this->curl->setHeaders([ @@ -74,14 +106,16 @@ public function verifyTransaction(string $reference) { 'Content-Type' => 'application/json' ]); - $this->curl->get('https://api.flutterwave.com/v3/transactions/' . $reference . '/verify'); + $this->curl->get('https://api.flutterwave.com/v3/transactions/' . $transactionId . '/verify'); $response = json_decode($this->curl->getBody(), true); - if( isset( $response[ 'data' ] ) ) { + if (isset($response['data']['id'], $response['data']['tx_ref'], $response['data']['status'], $response['data']['amount'], $response['data']['currency'])) { return [ + 'id' => (string) $response['data']['id'], + 'tx_ref' => (string) $response['data']['tx_ref'], 'amount' => $response['data']['amount'], - 'currency' => $response['data']['currency'], - 'status' => $response['data']['status'], + 'currency' => (string) $response['data']['currency'], + 'status' => (string) $response['data']['status'], ]; } diff --git a/README.md b/README.md index e119ee3..ce3c0f0 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Available settings: - `Enable Flutterwave Payment`: Enable/disable the payment method - `Title`: The title shown to customers at checkout - `API Secret Key`: Secret API key used to sign requests +- `Webhook Secret Hash`: Must match the Secret Hash configured for webhooks in the Flutterwave dashboard. Webhooks without a matching `verif-hash` header are rejected, and all webhooks are rejected while this is empty. - `API Public Key`: Public API key / identifier - `Sort Order`: Display order among payment methods - `Webhook URL`: A label/readonly field that shows the webhook endpoint you should register in the Flutterwave dashboard @@ -67,7 +68,7 @@ Register the webhook endpoint at your Flutterwave merchant dashboard. The webhoo /flutterwave/payment/webhook ``` -Replace `` with your store's base URL (for example `https://store.example.com/flutterwave/payment/webhook`). The module exposes controllers to handle webhook notifications and payment callbacks. +Replace `` with your store's base URL (for example `https://store.example.com/flutterwave/payment/webhook`). Set a Secret Hash on the same dashboard page and enter the same value in the module's `Webhook Secret Hash` setting. The module exposes controllers to handle webhook notifications and payment callbacks. ## Usage diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 360978c..8f81acc 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -23,6 +23,12 @@ + + + Magento\Config\Model\Config\Backend\Encrypted + Must match the Secret Hash set on the Flutterwave dashboard webhook settings. Webhooks are rejected while this is empty. + + Flutterwave\Payment\Model\Config\Source\Environment From 7b67800f89121d2d957e519aec128df020233f0f Mon Sep 17 00:00:00 2001 From: bajoski34 Date: Fri, 18 Sep 2026 13:31:33 +0100 Subject: [PATCH 2/2] add test suite --- .github/workflows/release-zip.yml | 5 + .github/workflows/tests.yml | 96 ++++++ .gitignore | 5 + Controller/Debug/Check.php | 26 +- Gateway/Command/VerifyCommand.php | 2 +- Model/Api/Client.php | 4 +- README.md | 14 +- Test/Unit/Block/Adminhtml/WebhookUrlTest.php | 31 ++ Test/Unit/Controller/Debug/CheckTest.php | 57 ++++ Test/Unit/Controller/Payment/CallbackTest.php | 288 ++++++++++++++++ Test/Unit/Controller/Payment/WebhookTest.php | 323 ++++++++++++++++++ Test/Unit/Controller/Redirect/IndexTest.php | 64 ++++ .../Gateway/Command/InitializeCommandTest.php | 54 +++ .../Gateway/Command/VerifyCommandTest.php | 85 +++++ Test/Unit/Model/Api/ClientTest.php | 70 ++++ Test/Unit/Model/Config/EnvironmentTest.php | 18 + .../Logger/FlutterwaveSignozLoggerTest.php | 153 +++++++++ .../Model/Payment/FlutterwaveAdapterTest.php | 129 +++++++ Test/Unit/Model/PaymentTest.php | 169 +++++++++ Test/bootstrap.php | 25 ++ Test/coverage-check.php | 47 +++ composer.json | 49 +++ phpunit.xml.dist | 31 ++ 23 files changed, 1731 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 Test/Unit/Block/Adminhtml/WebhookUrlTest.php create mode 100644 Test/Unit/Controller/Debug/CheckTest.php create mode 100644 Test/Unit/Controller/Payment/CallbackTest.php create mode 100644 Test/Unit/Controller/Payment/WebhookTest.php create mode 100644 Test/Unit/Controller/Redirect/IndexTest.php create mode 100644 Test/Unit/Gateway/Command/InitializeCommandTest.php create mode 100644 Test/Unit/Gateway/Command/VerifyCommandTest.php create mode 100644 Test/Unit/Model/Api/ClientTest.php create mode 100644 Test/Unit/Model/Config/EnvironmentTest.php create mode 100644 Test/Unit/Model/Logger/FlutterwaveSignozLoggerTest.php create mode 100644 Test/Unit/Model/Payment/FlutterwaveAdapterTest.php create mode 100644 Test/Unit/Model/PaymentTest.php create mode 100644 Test/bootstrap.php create mode 100644 Test/coverage-check.php create mode 100644 composer.json create mode 100644 phpunit.xml.dist diff --git a/.github/workflows/release-zip.yml b/.github/workflows/release-zip.yml index 9a83d99..e00cff4 100644 --- a/.github/workflows/release-zip.yml +++ b/.github/workflows/release-zip.yml @@ -28,6 +28,11 @@ jobs: -x "node_modules/*" \ -x "vendor/*" \ -x "var/*" \ + -x "Test/*" \ + -x "build/*" \ + -x ".phpunit.cache/*" \ + -x "phpunit.xml.dist" \ + -x "composer.lock" \ -x "*.DS_Store" echo "ARCHIVE_NAME=$ARCHIVE_NAME" >> "$GITHUB_ENV" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f57fccc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,96 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +env: + MIN_COVERAGE: 90 + # Unit tests mock Magento, so the PHP extensions a full Magento install needs are not required. + COMPOSER_OPTIONS: --ignore-platform-req=ext-* --no-scripts + +jobs: + component: + name: ${{ matrix.component }} (coverage >= 90%) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + component: [Block, Controller, Gateway, Model] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: pcov + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + composer-options: ${{ env.COMPOSER_OPTIONS }} + + - name: Run ${{ matrix.component }} tests + run: vendor/bin/phpunit --testsuite "${{ matrix.component }}" --coverage-clover "build/coverage/${{ matrix.component }}.xml" + + - name: Enforce ${{ env.MIN_COVERAGE }}% line coverage + shell: bash + run: | + set -o pipefail + { + echo "### ${{ matrix.component }} coverage" + echo '```' + php Test/coverage-check.php "build/coverage/${{ matrix.component }}.xml" "$MIN_COVERAGE" "${{ matrix.component }}" + echo '```' + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.component }} + path: build/coverage/${{ matrix.component }}.xml + if-no-files-found: ignore + + compatibility: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Each PHP version resolves to the newest Magento framework release that supports it. + php: ['8.1', '8.2', '8.3', '8.4'] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + + - name: Install dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: highest + composer-options: ${{ env.COMPOSER_OPTIONS }} + + - name: Run full test suite + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2db1b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/composer.lock +/build/ +/.phpunit.cache/ +.DS_Store diff --git a/Controller/Debug/Check.php b/Controller/Debug/Check.php index 1206f04..57357b3 100644 --- a/Controller/Debug/Check.php +++ b/Controller/Debug/Check.php @@ -3,34 +3,38 @@ use Magento\Payment\Helper\Data as PaymentHelper; use Magento\Framework\App\ActionInterface; +use Magento\Framework\Controller\Result\RawFactory; use Magento\Framework\Controller\ResultInterface; class Check implements ActionInterface { protected $paymentHelper; + protected $resultRawFactory; public function __construct( - PaymentHelper $paymentHelper + PaymentHelper $paymentHelper, + RawFactory $resultRawFactory ) { $this->paymentHelper = $paymentHelper; + $this->resultRawFactory = $resultRawFactory; } - public function execute() + public function execute(): ResultInterface { try { $paymentMethod = $this->paymentHelper->getMethodInstance('flutterwave'); - echo "

Flutterwave Payment Method Debug

"; - echo "
";
-            echo "Is Active: " . ($paymentMethod->isActive() ? 'Yes' : 'No') . "\n";
-            echo "Can Use Checkout: " . ($paymentMethod->canUseCheckout() ? 'Yes' : 'No') . "\n";
-            echo "Is Available: " . ($paymentMethod->isAvailable() ? 'Yes' : 'No') . "\n";
-            echo "
"; + $output = "

Flutterwave Payment Method Debug

"; + $output .= "
";
+            $output .= "Is Active: " . ($paymentMethod->isActive() ? 'Yes' : 'No') . "\n";
+            $output .= "Can Use Checkout: " . ($paymentMethod->canUseCheckout() ? 'Yes' : 'No') . "\n";
+            $output .= "Is Available: " . ($paymentMethod->isAvailable() ? 'Yes' : 'No') . "\n";
+            $output .= "
"; } catch (\Exception $e) { - echo "

Error

"; - echo "An error occurred: " . $e->getMessage(); + $output = "

Error

"; + $output .= "An error occurred: " . htmlspecialchars($e->getMessage(), ENT_QUOTES); } - exit; + return $this->resultRawFactory->create()->setContents($output); } } diff --git a/Gateway/Command/VerifyCommand.php b/Gateway/Command/VerifyCommand.php index c80af8c..acc9b31 100644 --- a/Gateway/Command/VerifyCommand.php +++ b/Gateway/Command/VerifyCommand.php @@ -25,7 +25,7 @@ public function execute(array $commandSubject) $response = $this->apiClient->verifyTransaction($transactionId); - if ($response['status'] !== 'successful') { + if (($response['status'] ?? null) !== 'successful') { throw new \Exception('Payment verification failed'); } diff --git a/Model/Api/Client.php b/Model/Api/Client.php index e80fecd..ff78aff 100644 --- a/Model/Api/Client.php +++ b/Model/Api/Client.php @@ -4,6 +4,7 @@ use Magento\Framework\HTTP\Client\Curl; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Framework\UrlInterface; +use Magento\Store\Model\ScopeInterface; class Client { protected $curl; @@ -44,8 +45,9 @@ public function createPaymentLink(array $payload = []) { $this->curl->post('https://api.flutterwave.com/v3/payments', json_encode($requestData)); $response = json_decode($this->curl->getBody(), true); + // Callers read $response['link'], so return the response data rather than the bare link. if (isset($response['data']['link'])) { - return $response['data']['link']; + return $response['data']; } return false; } diff --git a/README.md b/README.md index ce3c0f0..bfacc27 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,22 @@ Important code locations: - To customize request/response handling, modify `Model/Api/Client.php` and `Model/Payment/FlutterwaveAdapter.php`. - To change checkout behaviors or UI, edit the KnockoutJS view models in `view/frontend/web/js/view/payment/` and the template `view/frontend/web/template/payment/flutterwave.html`. -- Add unit/integration tests alongside the relevant classes where needed. +- Add unit tests under `Test/Unit/` mirroring the class path. ## Testing & debugging +Unit tests live in `Test/Unit` and run without a Magento installation; Magento packages come from the public [Mage-OS mirror](https://mirror.mage-os.org/). + +```bash +composer update --ignore-platform-req='ext-*' --no-scripts +vendor/bin/phpunit # all tests +vendor/bin/phpunit --testsuite Controller # one component: Block, Controller, Gateway or Model +vendor/bin/phpunit --testsuite Controller --coverage-clover build/coverage/Controller.xml +php Test/coverage-check.php build/coverage/Controller.xml 90 Controller # fails below 90% line coverage +``` + +Coverage needs pcov or Xdebug. The `Tests` GitHub workflow runs each component separately and fails if its line coverage drops below 90%, and runs the full suite on PHP 8.1–8.4. + - Enable developer mode in Magento to see error output: `php bin/magento deploy:mode:set developer`. - Check `var/log/` and `var/report` for exceptions and debug messages. - Use `bin/magento setup:di:compile` after changing PHP classes. diff --git a/Test/Unit/Block/Adminhtml/WebhookUrlTest.php b/Test/Unit/Block/Adminhtml/WebhookUrlTest.php new file mode 100644 index 0000000..751dde0 --- /dev/null +++ b/Test/Unit/Block/Adminhtml/WebhookUrlTest.php @@ -0,0 +1,31 @@ +createMock(UrlInterface::class); + $urlBuilder->expects($this->once())->method('getUrl')->with( + 'flutterwave/payment/webhook', + ['_scope' => 0, '_nosid' => true, '_direct' => 'flutterwave/payment/webhook'] + )->willReturn('https://shop.test/flutterwave/payment/webhook'); + + $objectManager = new ObjectManager($this); + $objectManager->prepareObjectManager(); + $block = $objectManager->getObject(WebhookUrl::class, ['urlBuilder' => $urlBuilder]); + + $method = new \ReflectionMethod($block, '_getElementHtml'); + $html = $method->invoke($block, $this->createMock(AbstractElement::class)); + + $this->assertStringContainsString('https://shop.test/flutterwave/payment/webhook', $html); + } +} diff --git a/Test/Unit/Controller/Debug/CheckTest.php b/Test/Unit/Controller/Debug/CheckTest.php new file mode 100644 index 0000000..dc407b5 --- /dev/null +++ b/Test/Unit/Controller/Debug/CheckTest.php @@ -0,0 +1,57 @@ +paymentHelper = $this->createMock(PaymentHelper::class); + $this->raw = $this->createMock(Raw::class); + $this->raw->method('setContents')->willReturnCallback(function (string $contents) { + $this->contents = $contents; + return $this->raw; + }); + $rawFactory = $this->createMock(RawFactory::class); + $rawFactory->method('create')->willReturn($this->raw); + + $this->controller = new Check($this->paymentHelper, $rawFactory); + } + + public function testReportsPaymentMethodAvailability(): void + { + $method = $this->createMock(MethodInterface::class); + $method->method('isActive')->willReturn(true); + $method->method('canUseCheckout')->willReturn(false); + $method->method('isAvailable')->willReturn(true); + $this->paymentHelper->method('getMethodInstance')->with('flutterwave')->willReturn($method); + + $this->assertSame($this->raw, $this->controller->execute()); + $this->assertStringContainsString("Is Active: Yes\n", $this->contents); + $this->assertStringContainsString("Can Use Checkout: No\n", $this->contents); + $this->assertStringContainsString("Is Available: Yes\n", $this->contents); + } + + public function testReportsErrorsEscaped(): void + { + $this->paymentHelper->method('getMethodInstance')->willThrowException(new \Exception('missing')); + + $this->controller->execute(); + $this->assertStringContainsString('

Error

', $this->contents); + $this->assertStringContainsString('<b>missing</b>', $this->contents); + } +} diff --git a/Test/Unit/Controller/Payment/CallbackTest.php b/Test/Unit/Controller/Payment/CallbackTest.php new file mode 100644 index 0000000..0fdd428 --- /dev/null +++ b/Test/Unit/Controller/Payment/CallbackTest.php @@ -0,0 +1,288 @@ +payment = $this->createMock(Payment::class); + $this->payment->method('getOrderIncrementIdFromTxRef')->willReturnCallback( + static fn (string $txRef) => $txRef === self::TX_REF ? '000000055' : null + ); + + $this->request = $this->createMock(RequestInterface::class); + $this->messageManager = $this->createMock(ManagerInterface::class); + $this->signozLogger = $this->createMock(FlutterwaveSignozLogger::class); + + $this->redirect = $this->createMock(Redirect::class); + $this->redirect->method('setPath')->willReturnCallback(function (string $path) { + $this->redirectPath = $path; + return $this->redirect; + }); + $redirectFactory = $this->createMock(RedirectFactory::class); + $redirectFactory->method('create')->willReturn($this->redirect); + + $this->orderPayment = $this->createMock(OrderPayment::class); + $this->orderPayment->method('setLastTransId')->willReturnSelf(); + $this->orderPayment->method('setAdditionalInformation')->willReturnSelf(); + + $this->order = $this->createMock(Order::class); + $this->order->method('getId')->willReturn(55); + $this->order->method('getIncrementId')->willReturn('000000055'); + $this->order->method('getGrandTotal')->willReturn(100.0); + $this->order->method('getOrderCurrencyCode')->willReturn('NGN'); + $this->order->method('getPayment')->willReturn($this->orderPayment); + $this->order->method('setState')->willReturnSelf(); + $this->order->method('setStatus')->willReturnSelf(); + $this->order->method('addCommentToStatusHistory')->willReturnSelf(); + $this->order->method('loadByIncrementId')->willReturnSelf(); + + $this->orderFactory = $this->createMock(OrderFactory::class); + $this->orderFactory->method('create')->willReturn($this->order); + + $this->controller = $this->createController($this->orderFactory); + } + + private function createController(OrderFactory $orderFactory): Callback + { + $redirectFactory = $this->createMock(RedirectFactory::class); + $redirectFactory->method('create')->willReturn($this->redirect); + + return new Callback( + $orderFactory, + $this->createMock(Session::class), + $this->payment, + $redirectFactory, + $this->createMock(JsonFactory::class), + $this->createMock(LoggerInterface::class), + $this->messageManager, + $this->request, + $this->signozLogger + ); + } + + private function givenParams(array $params): void + { + $this->request->method('getParams')->willReturn($params); + } + + private function givenVerification(array $overrides = []): void + { + $this->payment->method('verifyTransaction')->with('999111')->willReturn(array_merge([ + 'id' => '999111', + 'tx_ref' => self::TX_REF, + 'amount' => 100, + 'currency' => 'NGN', + 'status' => 'successful', + ], $overrides)); + } + + private function redirectParams(array $overrides = []): array + { + return array_merge([ + 'client_reference' => '000000055', + 'status' => 'successful', + 'tx_ref' => self::TX_REF, + 'transaction_id' => '999111', + ], $overrides); + } + + public function testMarksOrderProcessingForVerifiedSuccessfulPayment(): void + { + $this->givenParams($this->redirectParams()); + $this->givenVerification(); + $this->order->method('getState')->willReturn(Order::STATE_NEW); + + $this->order->expects($this->once())->method('setState')->with(Order::STATE_PROCESSING); + $this->order->expects($this->once())->method('save'); + $this->orderPayment->expects($this->once())->method('setLastTransId')->with('999111'); + $this->signozLogger->expects($this->once())->method('trackTransaction'); + + $this->assertSame($this->redirect, $this->controller->execute()); + $this->assertSame('checkout/onepage/success', $this->redirectPath); + } + + public function testCancelledRedirectWithoutTransactionIdLeavesOrderPending(): void + { + $this->givenParams(['client_reference' => '000000055', 'status' => 'cancelled', 'tx_ref' => self::TX_REF]); + $this->payment->expects($this->never())->method('verifyTransaction'); + $this->orderFactory->expects($this->never())->method('create'); + $this->messageManager->expects($this->once())->method('addErrorMessage'); + + $this->controller->execute(); + $this->assertSame('checkout/cart', $this->redirectPath); + } + + public function testUnverifiableTransactionChangesNothing(): void + { + $this->givenParams($this->redirectParams()); + $this->payment->method('verifyTransaction')->willReturn([]); + $this->order->expects($this->never())->method('save'); + $this->signozLogger->expects($this->once())->method('trackError')->with('callback.exception'); + + $this->controller->execute(); + $this->assertSame('checkout/cart', $this->redirectPath); + } + + public function testRejectsTransactionBelongingToAnotherOrder(): void + { + // Finding 5: attacker's own transaction presented against someone else's order. + $this->givenParams($this->redirectParams(['client_reference' => '000000077'])); + $this->givenVerification(); + $this->orderFactory->expects($this->never())->method('create'); + + $this->controller->execute(); + $this->assertSame('checkout/cart', $this->redirectPath); + } + + public function testRejectsTransactionNotCreatedByThisStore(): void + { + $this->givenParams($this->redirectParams()); + $this->givenVerification(['tx_ref' => 'WOO_1_abc']); + $this->orderFactory->expects($this->never())->method('create'); + + $this->controller->execute(); + $this->assertSame('checkout/cart', $this->redirectPath); + } + + public function testFindsOrderFromVerifiedTxRefWhenClientReferenceAbsent(): void + { + $this->givenParams($this->redirectParams(['client_reference' => null])); + $this->givenVerification(); + $this->order->method('getState')->willReturn(Order::STATE_PENDING_PAYMENT); + $this->order->expects($this->once())->method('loadByIncrementId')->with('000000055'); + + $this->controller->execute(); + $this->assertSame('checkout/onepage/success', $this->redirectPath); + } + + public function testMissingOrderRedirectsToCart(): void + { + $this->givenParams($this->redirectParams()); + $this->givenVerification(); + $missing = $this->createMock(Order::class); + $missing->method('loadByIncrementId')->willReturnSelf(); + $missing->method('getId')->willReturn(null); + $orderFactory = $this->createMock(OrderFactory::class); + $orderFactory->method('create')->willReturn($missing); + $this->messageManager->expects($this->once())->method('addErrorMessage'); + + $this->createController($orderFactory)->execute(); + $this->assertSame('checkout/cart', $this->redirectPath); + } + + public static function paidStateProvider(): array + { + return [[Order::STATE_PROCESSING], [Order::STATE_COMPLETE]]; + } + + #[DataProvider('paidStateProvider')] + public function testAlreadyPaidOrderGoesToSuccessPage(string $state): void + { + $this->givenParams($this->redirectParams()); + $this->givenVerification(); + $this->order->method('getState')->willReturn($state); + $this->order->expects($this->never())->method('save'); + + $this->controller->execute(); + $this->assertSame('checkout/onepage/success', $this->redirectPath); + } + + public static function closedStateProvider(): array + { + return [[Order::STATE_CANCELED], [Order::STATE_HOLDED], [Order::STATE_CLOSED]]; + } + + #[DataProvider('closedStateProvider')] + public function testClosedOrderIsNotReopened(string $state): void + { + $this->givenParams($this->redirectParams()); + $this->givenVerification(); + $this->order->method('getState')->willReturn($state); + $this->order->expects($this->never())->method('save'); + + $this->controller->execute(); + $this->assertSame('checkout/cart', $this->redirectPath); + } + + public static function unsuccessfulStatusProvider(): array + { + return [['failed'], ['pending'], ['success']]; + } + + #[DataProvider('unsuccessfulStatusProvider')] + public function testUnsuccessfulPaymentNeverCancelsOrder(string $status): void + { + $this->givenParams($this->redirectParams(['status' => 'successful'])); + $this->givenVerification(['status' => $status]); + $this->order->method('getState')->willReturn(Order::STATE_NEW); + + $this->order->expects($this->never())->method('setState'); + $this->order->expects($this->never())->method('save'); + $this->messageManager->expects($this->once())->method('addErrorMessage'); + + $this->controller->execute(); + $this->assertSame('checkout/cart', $this->redirectPath); + } + + public static function amountMismatchProvider(): array + { + return [ + 'amount' => [['amount' => 1]], + 'currency' => [['currency' => 'USD']], + ]; + } + + #[DataProvider('amountMismatchProvider')] + public function testHoldsAndSavesOrderOnAmountOrCurrencyMismatch(array $overrides): void + { + $this->givenParams($this->redirectParams()); + $this->givenVerification($overrides); + $this->order->method('getState')->willReturn(Order::STATE_NEW); + + $this->order->expects($this->once())->method('setState')->with(Order::STATE_HOLDED); + $this->order->expects($this->once())->method('save'); + + $this->controller->execute(); + $this->assertSame('checkout/cart', $this->redirectPath); + } + + public function testAmountsEqual(): void + { + $this->assertTrue($this->controller->amounts_equal(100, '100.009')); + $this->assertFalse($this->controller->amounts_equal(100, 99)); + } +} diff --git a/Test/Unit/Controller/Payment/WebhookTest.php b/Test/Unit/Controller/Payment/WebhookTest.php new file mode 100644 index 0000000..b2d899f --- /dev/null +++ b/Test/Unit/Controller/Payment/WebhookTest.php @@ -0,0 +1,323 @@ +payment = $this->createMock(Payment::class); + $this->payment->method('getSecretHash')->willReturn(self::SECRET); + $this->payment->method('getOrderIncrementIdFromTxRef')->willReturnCallback( + static fn (string $txRef) => $txRef === self::TX_REF ? '000000055' : null + ); + + $this->request = $this->createMock(HttpRequest::class); + $this->signozLogger = $this->createMock(FlutterwaveSignozLogger::class); + + $this->result = $this->createMock(Json::class); + $this->result->method('setData')->willReturnCallback(function (array $data) { + $this->responseData = $data; + return $this->result; + }); + $this->result->method('setHttpResponseCode')->willReturnCallback(function (int $code) { + $this->responseCode = $code; + return $this->result; + }); + $jsonFactory = $this->createMock(JsonFactory::class); + $jsonFactory->method('create')->willReturn($this->result); + + $this->orderPayment = $this->createMock(OrderPayment::class); + $this->orderPayment->method('setLastTransId')->willReturnSelf(); + $this->orderPayment->method('setAdditionalInformation')->willReturnSelf(); + + $this->order = $this->createMock(Order::class); + $this->order->method('getId')->willReturn(55); + $this->order->method('getIncrementId')->willReturn('000000055'); + $this->order->method('getGrandTotal')->willReturn(100.0); + $this->order->method('getOrderCurrencyCode')->willReturn('NGN'); + $this->order->method('getPayment')->willReturn($this->orderPayment); + $this->order->method('setState')->willReturnSelf(); + $this->order->method('setStatus')->willReturnSelf(); + $this->order->method('addCommentToStatusHistory')->willReturnSelf(); + $this->order->method('loadByIncrementId')->willReturnSelf(); + + $this->orderFactory = $this->createMock(OrderFactory::class); + $this->orderFactory->method('create')->willReturn($this->order); + + $this->controller = new Webhook( + $this->createMock(LoggerInterface::class), + $this->payment, + $jsonFactory, + $this->request, + $this->orderFactory, + $this->signozLogger + ); + } + + private function givenRequest(?string $signature, array $payload): void + { + $this->request->method('getHeader')->with('verif-hash')->willReturn($signature ?? false); + $this->request->method('getContent')->willReturn(json_encode($payload)); + } + + private function givenVerification(array $overrides = []): void + { + $this->payment->method('verifyTransaction')->with('999111')->willReturn(array_merge([ + 'id' => '999111', + 'tx_ref' => self::TX_REF, + 'amount' => 100, + 'currency' => 'NGN', + 'status' => 'successful', + ], $overrides)); + } + + private function chargeCompleted(array $data = []): array + { + return ['event' => 'charge.completed', 'data' => array_merge(['id' => 999111, 'tx_ref' => self::TX_REF, 'status' => 'successful'], $data)]; + } + + public static function badSignatureProvider(): array + { + return [ + 'missing header' => [null], + 'empty header' => [''], + 'wrong hash' => ['guess'], + ]; + } + + #[DataProvider('badSignatureProvider')] + public function testRejectsRequestsWithoutValidSignature(?string $signature): void + { + $this->givenRequest($signature, $this->chargeCompleted()); + $this->payment->expects($this->never())->method('verifyTransaction'); + $this->order->expects($this->never())->method('save'); + + $this->assertSame($this->result, $this->controller->execute()); + $this->assertSame(401, $this->responseCode); + $this->assertFalse($this->responseData['success']); + } + + public function testRejectsAllRequestsWhenSecretHashNotConfigured(): void + { + $payment = $this->createMock(Payment::class); + $payment->method('getSecretHash')->willReturn(''); + $payment->expects($this->never())->method('verifyTransaction'); + $this->request->method('getHeader')->willReturn(''); + + $jsonFactory = $this->createMock(JsonFactory::class); + $jsonFactory->method('create')->willReturn($this->result); + $controller = new Webhook($this->createMock(LoggerInterface::class), $payment, $jsonFactory, $this->request, $this->orderFactory, $this->signozLogger); + + $controller->execute(); + $this->assertSame(401, $this->responseCode); + } + + public static function invalidPayloadProvider(): array + { + return [ + 'not json' => [null], + 'missing id' => [['data' => ['tx_ref' => self::TX_REF]]], + 'non numeric id' => [['data' => ['id' => '99/../1']]], + ]; + } + + #[DataProvider('invalidPayloadProvider')] + public function testRejectsPayloadWithoutNumericTransactionId(?array $payload): void + { + $this->request->method('getHeader')->willReturn(self::SECRET); + $this->request->method('getContent')->willReturn($payload === null ? 'not-json' : json_encode($payload)); + $this->payment->expects($this->never())->method('verifyTransaction'); + + $this->controller->execute(); + $this->assertSame(400, $this->responseCode); + } + + public function testReturnsBadGatewayWhenTransactionCannotBeVerified(): void + { + $this->givenRequest(self::SECRET, $this->chargeCompleted()); + $this->payment->method('verifyTransaction')->willReturn([]); + $this->order->expects($this->never())->method('save'); + + $this->controller->execute(); + $this->assertSame(502, $this->responseCode); + } + + public function testMarksOrderProcessingForVerifiedSuccessfulPayment(): void + { + $this->givenRequest(self::SECRET, $this->chargeCompleted()); + $this->givenVerification(); + $this->order->method('getState')->willReturn(Order::STATE_PENDING_PAYMENT); + + $this->order->expects($this->once())->method('setState')->with(Order::STATE_PROCESSING); + $this->order->expects($this->once())->method('save'); + $this->orderPayment->expects($this->once())->method('setLastTransId')->with('999111'); + $this->signozLogger->expects($this->once())->method('trackTransaction'); + + $this->controller->execute(); + $this->assertSame(200, $this->responseCode); + $this->assertSame('Order Processed Successfully', $this->responseData['message']); + } + + public function testUsesVerifiedTxRefNotPayloadToFindOrder(): void + { + // Payload claims to be for a different order; only the verified tx_ref counts. + $this->givenRequest(self::SECRET, $this->chargeCompleted(['tx_ref' => 'MAG_000000099_x', 'status' => 'failed'])); + $this->givenVerification(); + $this->order->method('getState')->willReturn(Order::STATE_NEW); + + $this->payment->expects($this->once())->method('getOrderIncrementIdFromTxRef')->with(self::TX_REF); + $this->order->expects($this->once())->method('loadByIncrementId')->with('000000055'); + $this->order->expects($this->once())->method('setState')->with(Order::STATE_PROCESSING); + + $this->controller->execute(); + } + + public static function amountMismatchProvider(): array + { + return [ + 'amount' => [['amount' => 1]], + 'currency' => [['currency' => 'USD']], + ]; + } + + #[DataProvider('amountMismatchProvider')] + public function testHoldsAndSavesOrderOnAmountOrCurrencyMismatch(array $overrides): void + { + $this->givenRequest(self::SECRET, $this->chargeCompleted()); + $this->givenVerification($overrides); + $this->order->method('getState')->willReturn(Order::STATE_NEW); + + $this->order->expects($this->once())->method('setState')->with(Order::STATE_HOLDED); + $this->order->expects($this->once())->method('addCommentToStatusHistory'); + $this->order->expects($this->once())->method('save'); + + $this->controller->execute(); + $this->assertSame(200, $this->responseCode); + $this->assertSame('Order placed on hold', $this->responseData['message']); + } + + public static function unsuccessfulStatusProvider(): array + { + return [['failed'], ['pending'], ['cancelled'], ['success']]; + } + + #[DataProvider('unsuccessfulStatusProvider')] + public function testNeverCancelsOrChangesOrderForUnsuccessfulPayment(string $status): void + { + $this->givenRequest(self::SECRET, $this->chargeCompleted(['status' => 'successful'])); + $this->givenVerification(['status' => $status]); + $this->order->method('getState')->willReturn(Order::STATE_PENDING_PAYMENT); + + $this->order->expects($this->never())->method('setState'); + $this->order->expects($this->never())->method('save'); + + $this->controller->execute(); + $this->assertSame(200, $this->responseCode); + } + + public static function finalStateProvider(): array + { + return [[Order::STATE_PROCESSING], [Order::STATE_COMPLETE], [Order::STATE_CANCELED], [Order::STATE_HOLDED], [Order::STATE_CLOSED]]; + } + + #[DataProvider('finalStateProvider')] + public function testLeavesOrdersOutsidePendingStatesUntouched(string $state): void + { + $this->givenRequest(self::SECRET, $this->chargeCompleted()); + $this->givenVerification(); + $this->order->method('getState')->willReturn($state); + + $this->order->expects($this->never())->method('save'); + + $this->controller->execute(); + $this->assertSame(200, $this->responseCode); + $this->assertSame('Order Already Processed', $this->responseData['message']); + } + + public function testAcknowledgesTransactionsNotCreatedByThisStore(): void + { + $this->givenRequest(self::SECRET, $this->chargeCompleted()); + $this->givenVerification(['tx_ref' => 'WOO_123_abc']); + $this->orderFactory->expects($this->never())->method('create'); + + $this->controller->execute(); + $this->assertSame(200, $this->responseCode); + $this->assertSame('Transaction not handled by this store.', $this->responseData['message']); + } + + public function testReturnsServerErrorWithoutLeakingDetailsWhenOrderMissing(): void + { + $this->givenRequest(self::SECRET, $this->chargeCompleted()); + $this->givenVerification(); + $missing = $this->createMock(Order::class); + $missing->method('loadByIncrementId')->willReturnSelf(); + $missing->method('getId')->willReturn(null); + $orderFactory = $this->createMock(OrderFactory::class); + $orderFactory->method('create')->willReturn($missing); + + $jsonFactory = $this->createMock(JsonFactory::class); + $jsonFactory->method('create')->willReturn($this->result); + $controller = new Webhook($this->createMock(LoggerInterface::class), $this->payment, $jsonFactory, $this->request, $orderFactory, $this->signozLogger); + $this->signozLogger->expects($this->once())->method('trackError')->with('webhook.exception'); + + $controller->execute(); + $this->assertSame(500, $this->responseCode); + $this->assertSame('Webhook processing failed', $this->responseData['message']); + } + + public function testCsrfValidationIsSkippedBecauseSignatureIsVerified(): void + { + $request = $this->createMock(RequestInterface::class); + + $this->assertNull($this->controller->createCsrfValidationException($request)); + $this->assertTrue($this->controller->validateForCsrf($request)); + } + + public static function amountsProvider(): array + { + return [ + 'equal' => [100.00, 100.00, true], + 'within epsilon' => [100.00, 100.005, true], + 'outside epsilon' => [100.00, 100.02, false], + 'numeric strings' => ['100.00', '100', true], + ]; + } + + #[DataProvider('amountsProvider')] + public function testAmountsEqual(float|string $a, float|string $b, bool $expected): void + { + $this->assertSame($expected, $this->controller->amounts_equal($a, $b)); + } +} diff --git a/Test/Unit/Controller/Redirect/IndexTest.php b/Test/Unit/Controller/Redirect/IndexTest.php new file mode 100644 index 0000000..b40dcd1 --- /dev/null +++ b/Test/Unit/Controller/Redirect/IndexTest.php @@ -0,0 +1,64 @@ +payment = $this->createMock(Payment::class); + $this->messageManager = $this->createMock(ManagerInterface::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->redirect = $this->createMock(Redirect::class); + $this->redirect->method('setUrl')->willReturnSelf(); + $this->redirect->method('setPath')->willReturnSelf(); + $redirectFactory = $this->createMock(RedirectFactory::class); + $redirectFactory->method('create')->willReturn($this->redirect); + + $this->controller = new Index($redirectFactory, $this->payment, $this->messageManager, $this->logger); + } + + public function testRedirectsToFlutterwaveCheckout(): void + { + $this->payment->method('startTransaction')->willReturn('https://checkout.flutterwave.com/pay/x'); + $this->redirect->expects($this->once())->method('setUrl')->with('https://checkout.flutterwave.com/pay/x'); + $this->redirect->expects($this->never())->method('setPath'); + $this->messageManager->expects($this->never())->method('addErrorMessage'); + + $this->assertSame($this->redirect, $this->controller->execute()); + } + + public function testReturnsToCartWhenNoLinkIsReturned(): void + { + $this->payment->method('startTransaction')->willReturn(false); + $this->redirect->expects($this->once())->method('setPath')->with('checkout/cart'); + $this->messageManager->expects($this->once())->method('addErrorMessage'); + + $this->assertSame($this->redirect, $this->controller->execute()); + } + + public function testReturnsToCartAndLogsWhenStartingTransactionFails(): void + { + $this->payment->method('startTransaction')->willThrowException(new \RuntimeException('boom')); + $this->logger->expects($this->once())->method('debug')->with($this->stringContains('boom')); + $this->redirect->expects($this->once())->method('setPath')->with('checkout/cart'); + + $this->assertSame($this->redirect, $this->controller->execute()); + } +} diff --git a/Test/Unit/Gateway/Command/InitializeCommandTest.php b/Test/Unit/Gateway/Command/InitializeCommandTest.php new file mode 100644 index 0000000..ff10de5 --- /dev/null +++ b/Test/Unit/Gateway/Command/InitializeCommandTest.php @@ -0,0 +1,54 @@ +createMock(Order::class); + $order->method('getIncrementId')->willReturn('000000055'); + $order->method('getGrandTotal')->willReturn(100.0); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $order->method('getStoreId')->willReturn(1); + $order->method('getCustomerEmail')->willReturn('buyer@example.com'); + $order->method('getCustomerName')->willReturn('Ada Buyer'); + + $payment = $this->createMock(Payment::class); + $payment->method('getOrder')->willReturn($order); + $paymentDO = $this->createMock(PaymentDataObjectInterface::class); + $paymentDO->method('getPayment')->willReturn($payment); + + $client = $this->createMock(Client::class); + $client->expects($this->once())->method('createPaymentLink')->with([ + 'tx_ref' => '000000055', + 'amount' => 100.0, + 'currency' => 'NGN', + 'redirect_url' => '', + 'customer' => ['email' => 'buyer@example.com', 'name' => 'Ada Buyer'], + ])->willReturn(['link' => 'https://checkout.flutterwave.com/x']); + + $session = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->addMethods(['setFlutterwavePaymentUrl']) + ->getMock(); + $session->expects($this->once())->method('setFlutterwavePaymentUrl')->with('https://checkout.flutterwave.com/x'); + + $stateObject = new DataObject(); + (new InitializeCommand($client, $session))->execute(['payment' => $paymentDO, 'stateObject' => $stateObject]); + + $this->assertSame(Order::STATE_PENDING_PAYMENT, $stateObject->getState()); + $this->assertSame(Order::STATE_PENDING_PAYMENT, $stateObject->getStatus()); + $this->assertFalse($stateObject->getIsNotified()); + } +} diff --git a/Test/Unit/Gateway/Command/VerifyCommandTest.php b/Test/Unit/Gateway/Command/VerifyCommandTest.php new file mode 100644 index 0000000..581f407 --- /dev/null +++ b/Test/Unit/Gateway/Command/VerifyCommandTest.php @@ -0,0 +1,85 @@ +client = $this->createMock(Client::class); + $this->payment = $this->createMock(Payment::class); + $this->payment->method('setTransactionId')->willReturnSelf(); + $this->payment->method('setIsTransactionClosed')->willReturnSelf(); + $this->payment->method('setAdditionalInformation')->willReturnSelf(); + $this->paymentDO = $this->createMock(PaymentDataObjectInterface::class); + $this->paymentDO->method('getPayment')->willReturn($this->payment); + } + + private function execute(): void + { + (new VerifyCommand($this->client))->execute(['payment' => $this->paymentDO, 'transaction_id' => '999111']); + } + + private function givenPaymentAction(string $action): void + { + $method = $this->createMock(MethodInterface::class); + $method->method('getConfigPaymentAction')->willReturn($action); + $this->payment->method('getMethodInstance')->willReturn($method); + } + + public static function unsuccessfulResponseProvider(): array + { + return [ + 'failed' => [['status' => 'failed']], + 'legacy success value' => [['status' => 'success']], + 'no response' => [null], + ]; + } + + #[DataProvider('unsuccessfulResponseProvider')] + public function testThrowsWhenTransactionIsNotSuccessful(?array $response): void + { + $this->client->method('verifyTransaction')->with('999111')->willReturn($response); + $this->payment->expects($this->never())->method('setTransactionId'); + + $this->expectExceptionMessage('Payment verification failed'); + $this->execute(); + } + + public function testRegistersCaptureForAuthorizeCapture(): void + { + $this->client->method('verifyTransaction')->willReturn(['status' => 'successful', 'amount' => 100]); + $this->givenPaymentAction('authorize_capture'); + + $this->payment->expects($this->once())->method('setTransactionId')->with('999111'); + $this->payment->expects($this->once())->method('registerCaptureNotification')->with(100); + $this->payment->expects($this->never())->method('authorize'); + + $this->execute(); + } + + public function testAuthorizesForOtherPaymentActions(): void + { + $this->client->method('verifyTransaction')->willReturn(['status' => 'successful', 'amount' => 100]); + $this->givenPaymentAction('authorize'); + + $this->payment->expects($this->once())->method('authorize')->with(true, 100); + $this->payment->expects($this->never())->method('registerCaptureNotification'); + + $this->execute(); + } +} diff --git a/Test/Unit/Model/Api/ClientTest.php b/Test/Unit/Model/Api/ClientTest.php new file mode 100644 index 0000000..5de828d --- /dev/null +++ b/Test/Unit/Model/Api/ClientTest.php @@ -0,0 +1,70 @@ + 100, + 'currency' => 'NGN', + 'order_id' => '000000055', + 'tx_ref' => 'MAG_000000055_ab1', + 'customer' => ['email' => 'buyer@example.com', 'name' => 'Ada Buyer'], + ]; + + protected function setUp(): void + { + $this->curl = $this->createMock(Curl::class); + $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->urlBuilder = $this->createMock(UrlInterface::class); + $this->client = new Client($this->curl, $this->scopeConfig, $this->urlBuilder); + } + + public function testCreatePaymentLinkPostsPayloadWithRedirectUrl(): void + { + $this->scopeConfig->method('getValue') + ->with('payment/flutterwave/api_key', ScopeInterface::SCOPE_STORE) + ->willReturn('FLWSECK_TEST'); + $this->urlBuilder->method('getUrl')->with('flutterwave/payment/callback')->willReturn('https://shop.test/callback'); + + $this->curl->expects($this->once())->method('setHeaders')->with([ + 'Authorization' => 'Bearer FLWSECK_TEST', + 'Content-Type' => 'application/json', + ]); + $this->curl->expects($this->once())->method('post')->with( + 'https://api.flutterwave.com/v3/payments', + json_encode($this->payload + ['redirect_url' => 'https://shop.test/callback']) + ); + $this->curl->method('getBody')->willReturn(json_encode(['status' => 'success', 'data' => ['link' => 'https://checkout.flutterwave.com/x']])); + + $this->assertSame(['link' => 'https://checkout.flutterwave.com/x'], $this->client->createPaymentLink($this->payload)); + } + + public function testCreatePaymentLinkReturnsFalseOnError(): void + { + $this->curl->method('getBody')->willReturn(json_encode(['status' => 'error', 'message' => 'Invalid key'])); + + $this->assertFalse($this->client->createPaymentLink($this->payload)); + } + + public function testVerifyTransactionIsNotImplemented(): void + { + $this->curl->expects($this->never())->method('get'); + + $this->assertNull($this->client->verifyTransaction('999111')); + } +} diff --git a/Test/Unit/Model/Config/EnvironmentTest.php b/Test/Unit/Model/Config/EnvironmentTest.php new file mode 100644 index 0000000..2d0fa34 --- /dev/null +++ b/Test/Unit/Model/Config/EnvironmentTest.php @@ -0,0 +1,18 @@ +toOptionArray(); + + $this->assertSame(['sandbox', 'production'], array_column($options, 'value')); + $this->assertSame(['Sandbox', 'Production'], array_map('strval', array_column($options, 'label'))); + } +} diff --git a/Test/Unit/Model/Logger/FlutterwaveSignozLoggerTest.php b/Test/Unit/Model/Logger/FlutterwaveSignozLoggerTest.php new file mode 100644 index 0000000..91b1c2f --- /dev/null +++ b/Test/Unit/Model/Logger/FlutterwaveSignozLoggerTest.php @@ -0,0 +1,153 @@ +scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->curl = $this->createMock(Curl::class); + $this->curl->method('post')->willReturnCallback(function (string $url, string $body): void { + $this->assertSame(self::ENDPOINT, $url); + $this->sent = json_decode($body, true); + }); + $this->logger = $this->createMock(LoggerInterface::class); + $this->signoz = new FlutterwaveSignozLogger($this->scopeConfig, $this->curl, $this->logger); + } + + private function givenStatus(int $status): void + { + $this->curl->method('getStatus')->willReturn($status); + } + + public function testTrackRequestSentPostsEvent(): void + { + $this->givenStatus(202); + $this->curl->expects($this->once())->method('setHeaders')->with([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]); + $this->logger->expects($this->never())->method('warning'); + + $this->signoz->trackRequestSent('webhook', ' MAG_1_x ', '/flutterwave/payment/webhook', ['status' => 'successful']); + + $this->assertSame('request.sent', $this->sent['name']); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.000Z$/', $this->sent['timestamp']); + $this->assertSame([ + 'app_id' => 'internal', + 'environment' => 'sandbox', + 'api_version' => 'v3', + 'library' => 'Magento', + 'library_version' => '2.x', + 'method' => 'webhook', + 'path' => '/flutterwave/payment/webhook', + 'reference' => 'MAG_1_x', + 'context' => ['status' => 'successful'], + ], $this->sent['data']); + } + + public function testTrackTransactionPostsEvent(): void + { + $this->givenStatus(200); + + $this->signoz->trackTransaction('MAG_1_x', 'NGN', 100.5, 'callback', 1.5, ['order_id' => '1']); + + $this->assertSame('app.transaction', $this->sent['name']); + $this->assertSame('NGN', $this->sent['data']['currency']); + $this->assertEquals(100.5, $this->sent['data']['amount']); + $this->assertEquals(1.5, $this->sent['data']['fee']); + $this->assertSame('callback', $this->sent['data']['method']); + $this->assertSame(['order_id' => '1'], $this->sent['data']['context']); + } + + public function testTrackErrorIncludesOptionalFieldsAndTruncates(): void + { + $this->givenStatus(200); + + $this->signoz->trackError('webhook.exception', str_repeat('m', 5000), 'bad ref/../x', str_repeat('s', 20000), ['a' => 1]); + + $data = $this->sent['data']; + $this->assertSame('app.error', $this->sent['name']); + $this->assertSame('webhook.exception', $data['error_code']); + $this->assertSame(4096, strlen($data['error_message'])); + $this->assertSame(16384, strlen($data['error_stacktrace'])); + $this->assertSame('bad-ref-x', $data['reference']); + } + + public function testTrackErrorOmitsEmptyOptionalFields(): void + { + $this->givenStatus(200); + + $this->signoz->trackError('callback.invalid', 'short', '', ''); + + $this->assertSame('short', $this->sent['data']['error_message']); + $this->assertArrayNotHasKey('error_stacktrace', $this->sent['data']); + $this->assertArrayNotHasKey('reference', $this->sent['data']); + } + + public function testReferenceWithOnlyInvalidCharactersIsReplaced(): void + { + $this->givenStatus(200); + + $this->signoz->trackRequestSent('callback', '', '/x'); + + $this->assertSame('unknown', $this->sent['data']['reference']); + } + + public static function environmentProvider(): array + { + return [ + ['live', 'production'], + ['production', 'production'], + ['1', 'production'], + ['sandbox', 'sandbox'], + [null, 'sandbox'], + ]; + } + + #[DataProvider('environmentProvider')] + public function testEnvironmentMapping(?string $configured, string $expected): void + { + $this->givenStatus(200); + $this->scopeConfig->method('getValue')->with('payment/flutterwave/environment')->willReturn($configured); + + $this->signoz->trackRequestSent('webhook', 'r', '/x'); + + $this->assertSame($expected, $this->sent['data']['environment']); + } + + public function testLogsWarningOnNonSuccessStatus(): void + { + $this->givenStatus(500); + $this->logger->expects($this->once())->method('warning')->with('Flutterwave SigNoz event failed with HTTP status: 500'); + + $this->signoz->trackRequestSent('webhook', 'r', '/x'); + } + + public function testNeverThrowsWhenTransportFails(): void + { + $curl = $this->createMock(Curl::class); + $curl->method('post')->willThrowException(new \RuntimeException('connection refused')); + $this->logger->expects($this->once())->method('warning')->with('Flutterwave SigNoz logger failed: connection refused'); + + (new FlutterwaveSignozLogger($this->scopeConfig, $curl, $this->logger))->trackRequestSent('webhook', 'r', '/x'); + } +} diff --git a/Test/Unit/Model/Payment/FlutterwaveAdapterTest.php b/Test/Unit/Model/Payment/FlutterwaveAdapterTest.php new file mode 100644 index 0000000..9c6f9a4 --- /dev/null +++ b/Test/Unit/Model/Payment/FlutterwaveAdapterTest.php @@ -0,0 +1,129 @@ + 'Flutterwave', 'api_key' => 'FLWSECK_TEST']; + + protected function setUp(): void + { + $this->client = $this->createMock(Client::class); + $this->session = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->addMethods(['setFlutterwavePaymentUrl']) + ->getMock(); + $this->urlBuilder = $this->createMock(UrlInterface::class); + $this->urlBuilder->method('getUrl')->with('flutterwave/payment/callback', ['_secure' => true])->willReturn('https://shop.test/callback'); + $this->signoz = $this->createMock(FlutterwaveSignozLogger::class); + } + + private function createAdapter(?FlutterwaveSignozLogger $signoz): FlutterwaveAdapter + { + $handler = $this->createMock(ValueHandlerInterface::class); + $handler->method('handle')->willReturnCallback(fn (array $subject) => $this->config[$subject['field']] ?? null); + $pool = $this->createMock(ValueHandlerPoolInterface::class); + $pool->method('get')->willReturn($handler); + + return new FlutterwaveAdapter( + $this->createMock(ManagerInterface::class), + $pool, + $this->createMock(PaymentDataObjectFactory::class), + FlutterwaveAdapter::CODE, + FormBlock::class, + InfoBlock::class, + $this->client, + $this->session, + $this->urlBuilder, + null, + null, + null, + $this->createMock(LoggerInterface::class), + $signoz + ); + } + + private function givenOrderOn(FlutterwaveAdapter $adapter): void + { + $order = $this->createMock(Order::class); + $order->method('getIncrementId')->willReturn('000000055'); + $order->method('getGrandTotal')->willReturn(100.0); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $order->method('getCustomerEmail')->willReturn('buyer@example.com'); + $order->method('getCustomerName')->willReturn('Ada Buyer'); + $payment = $this->createMock(Payment::class); + $payment->method('getOrder')->willReturn($order); + $adapter->setInfoInstance($payment); + } + + public function testInitializeCreatesLinkAndSetsPendingPayment(): void + { + $adapter = $this->createAdapter($this->signoz); + $this->givenOrderOn($adapter); + + $this->signoz->expects($this->once())->method('trackRequestSent')->with( + 'payment_link', + $this->matchesRegularExpression('/^MAG_000000055_ab[0-9a-f]+$/'), + '/v3/payments', + ['order_id' => '000000055', 'amount' => 100.0, 'currency' => 'NGN'] + ); + $this->client->expects($this->once())->method('createPaymentLink')->with($this->callback(function (array $payload): bool { + $this->assertMatchesRegularExpression('/^MAG_000000055_ab[0-9a-f]+$/', $payload['tx_ref']); + $this->assertSame('https://shop.test/callback', $payload['redirect_url']); + $this->assertSame(['email' => 'buyer@example.com', 'name' => 'Ada Buyer'], $payload['customer']); + $this->assertSame(['title' => 'Flutterwave', 'logo' => ''], $payload['customizations']); + return true; + }))->willReturn(['link' => 'https://checkout.flutterwave.com/x']); + $this->session->expects($this->once())->method('setFlutterwavePaymentUrl')->with('https://checkout.flutterwave.com/x'); + + $state = new DataObject(); + $this->assertSame($adapter, $adapter->initialize('authorize', $state)); + $this->assertSame(Order::STATE_PENDING_PAYMENT, $state->getState()); + $this->assertSame('pending_payment', $state->getStatus()); + $this->assertFalse($state->getIsNotified()); + } + + public function testInitializeWorksWithoutSignozLoggerAndFailsWithoutLink(): void + { + $adapter = $this->createAdapter(null); + $this->givenOrderOn($adapter); + $this->client->method('createPaymentLink')->willReturn(false); + $this->session->expects($this->never())->method('setFlutterwavePaymentUrl'); + + $this->expectException(LocalizedException::class); + $this->expectExceptionMessage('Could not generate Flutterwave payment link'); + $adapter->initialize('authorize', new DataObject()); + } + + public function testIsAvailableRequiresApiKey(): void + { + $this->assertTrue($this->createAdapter(null)->isAvailable()); + + $this->config['api_key'] = ''; + $this->assertFalse($this->createAdapter(null)->isAvailable()); + } +} diff --git a/Test/Unit/Model/PaymentTest.php b/Test/Unit/Model/PaymentTest.php new file mode 100644 index 0000000..3c6e4ed --- /dev/null +++ b/Test/Unit/Model/PaymentTest.php @@ -0,0 +1,169 @@ +checkoutSession = $this->getMockBuilder(Session::class) + ->disableOriginalConstructor() + ->onlyMethods(['getLastRealOrder']) + ->getMock(); + $this->curl = $this->createMock(Curl::class); + $this->urlBuilder = $this->createMock(UrlInterface::class); + $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->encryptor = $this->createMock(EncryptorInterface::class); + + $this->payment = new Payment( + $this->checkoutSession, + $this->createMock(Order::class), + $this->curl, + $this->urlBuilder, + $this->scopeConfig, + $this->encryptor + ); + } + + public function testGetSecretHashDecryptsConfiguredValue(): void + { + $this->scopeConfig->method('getValue')->with('payment/flutterwave/secret_hash')->willReturn('encrypted'); + $this->encryptor->expects($this->once())->method('decrypt')->with('encrypted')->willReturn('my-hash'); + + $this->assertSame('my-hash', $this->payment->getSecretHash()); + } + + public function testGetSecretHashReturnsEmptyWhenNotConfigured(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + $this->encryptor->expects($this->never())->method('decrypt'); + + $this->assertSame('', $this->payment->getSecretHash()); + } + + #[DataProvider('txRefProvider')] + public function testGetOrderIncrementIdFromTxRef(string $txRef, ?string $expected): void + { + $this->assertSame($expected, $this->payment->getOrderIncrementIdFromTxRef($txRef)); + } + + public static function txRefProvider(): array + { + return [ + 'valid' => ['MAG_000000055_old65f0a1', '000000055'], + 'wrong prefix' => ['WOO_000000055_old65f0a1', null], + 'non numeric id' => ['MAG_55a_old65f0a1', null], + 'missing suffix' => ['MAG_000000055', null], + 'extra segment' => ['MAG_000000055_x_y', null], + 'empty' => ['', null], + ]; + } + + public function testStartTransactionSendsV3PayloadAndReturnsLink(): void + { + $order = $this->createMock(Order::class); + $order->method('getGrandTotal')->willReturn(100.0); + $order->method('getOrderCurrencyCode')->willReturn('NGN'); + $order->method('getIncrementId')->willReturn('000000055'); + $order->method('getCustomerEmail')->willReturn('buyer@example.com'); + $this->checkoutSession->method('getLastRealOrder')->willReturn($order); + $this->scopeConfig->method('getValue')->with('payment/flutterwave/api_key')->willReturn('FLWSECK_TEST'); + $this->urlBuilder->method('getUrl')->with('flutterwave/payment/callback')->willReturn('https://shop.test/flutterwave/payment/callback/'); + + $this->curl->expects($this->once())->method('setHeaders')->with([ + 'Authorization' => 'Bearer FLWSECK_TEST', + 'Content-Type' => 'application/json', + ]); + $this->curl->expects($this->once())->method('post')->with( + 'https://api.flutterwave.com/v3/payments', + $this->callback(function (string $body): bool { + $data = json_decode($body, true); + $this->assertSame(100.0, (float) $data['amount']); + $this->assertSame('NGN', $data['currency']); + $this->assertMatchesRegularExpression('/^MAG_000000055_old[0-9a-f]+$/', $data['tx_ref']); + $this->assertSame('https://shop.test/flutterwave/payment/callback/?client_reference=000000055', $data['redirect_url']); + $this->assertSame(['email' => 'buyer@example.com'], $data['customer']); + $this->assertSame('000000055', $this->payment->getOrderIncrementIdFromTxRef($data['tx_ref'])); + return true; + }) + ); + $this->curl->method('getBody')->willReturn(json_encode(['data' => ['link' => 'https://checkout.flutterwave.com/x']])); + + $this->assertSame('https://checkout.flutterwave.com/x', $this->payment->startTransaction()); + } + + public function testStartTransactionReturnsFalseWithoutLink(): void + { + $this->checkoutSession->method('getLastRealOrder')->willReturn($this->createMock(Order::class)); + $this->curl->method('getBody')->willReturn(json_encode(['status' => 'error'])); + + $this->assertFalse($this->payment->startTransaction()); + } + + public function testVerifyTransactionReturnsNormalisedData(): void + { + $this->scopeConfig->method('getValue')->willReturn('FLWSECK_TEST'); + $this->curl->expects($this->once())->method('get')->with('https://api.flutterwave.com/v3/transactions/999111/verify'); + $this->curl->method('getBody')->willReturn(json_encode([ + 'status' => 'success', + 'data' => [ + 'id' => 999111, + 'tx_ref' => 'MAG_000000055_old1', + 'amount' => 100, + 'currency' => 'NGN', + 'status' => 'successful', + ], + ])); + + $this->assertSame([ + 'id' => '999111', + 'tx_ref' => 'MAG_000000055_old1', + 'amount' => 100, + 'currency' => 'NGN', + 'status' => 'successful', + ], $this->payment->verifyTransaction('999111')); + } + + #[DataProvider('nonNumericIdProvider')] + public function testVerifyTransactionRejectsNonNumericIdWithoutCallingApi(string $id): void + { + $this->curl->expects($this->never())->method('get'); + + $this->assertSame([], $this->payment->verifyTransaction($id)); + } + + public static function nonNumericIdProvider(): array + { + return [ + 'tx_ref' => ['MAG_000000055_old1'], + 'path traversal' => ['1/../../payments'], + 'empty' => [''], + ]; + } + + public function testVerifyTransactionReturnsEmptyForIncompleteResponse(): void + { + $this->curl->method('getBody')->willReturn(json_encode(['status' => 'error', 'data' => ['id' => 1]])); + + $this->assertSame([], $this->payment->verifyTransaction('1')); + } +} diff --git a/Test/bootstrap.php b/Test/bootstrap.php new file mode 100644 index 0000000..7ca19f5 --- /dev/null +++ b/Test/bootstrap.php @@ -0,0 +1,25 @@ + [path-prefix] + * With a path prefix (e.g. "Controller/"), only files under that directory are counted. + */ +declare(strict_types=1); + +[$script, $cloverFile, $threshold, $prefix] = array_pad($argv, 4, ''); + +if ($cloverFile === '' || !is_file($cloverFile) || !is_numeric($threshold)) { + fwrite(STDERR, "Usage: php {$script} [path-prefix]\n"); + exit(2); +} + +$root = rtrim(dirname(__DIR__), '/') . '/'; +$prefix = trim($prefix, '/'); +$covered = 0; +$total = 0; +$rows = []; + +foreach ((new SimpleXMLElement(file_get_contents($cloverFile)))->xpath('//file') as $file) { + $path = str_starts_with((string) $file['name'], $root) ? substr((string) $file['name'], strlen($root)) : (string) $file['name']; + if ($prefix !== '' && !str_starts_with($path, $prefix . '/')) { + continue; + } + $metrics = $file->metrics; + $statements = (int) $metrics['statements']; + $coveredStatements = (int) $metrics['coveredstatements']; + $total += $statements; + $covered += $coveredStatements; + if ($statements > 0) { + $rows[] = sprintf(' %6.2f%% %s', 100 * $coveredStatements / $statements, $path); + } +} + +if ($total === 0) { + fwrite(STDERR, "No executable lines found" . ($prefix !== '' ? " under {$prefix}/" : '') . ".\n"); + exit(1); +} + +$percent = 100 * $covered / $total; +echo implode("\n", $rows), "\n"; +printf("Line coverage%s: %.2f%% (%d/%d), minimum %.2f%%\n", $prefix !== '' ? " for {$prefix}" : '', $percent, $covered, $total, (float) $threshold); + +exit($percent + 1e-9 >= (float) $threshold ? 0 : 1); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..aead92e --- /dev/null +++ b/composer.json @@ -0,0 +1,49 @@ +{ + "name": "flutterwave/module-payment", + "description": "Flutterwave payment method for Magento 2", + "type": "magento2-module", + "license": "MIT", + "require": { + "php": ">=8.1" + }, + "require-dev": { + "magento/framework": "*", + "magento/module-backend": "*", + "magento/module-checkout": "*", + "magento/module-config": "*", + "magento/module-payment": "*", + "magento/module-quote": "*", + "magento/module-sales": "*", + "magento/module-store": "*", + "phpunit/phpunit": "^10.5" + }, + "repositories": [ + { + "type": "composer", + "url": "https://mirror.mage-os.org/", + "only": ["magento/*"] + } + ], + "autoload": { + "psr-4": { + "Flutterwave\\Payment\\": "" + }, + "files": [ + "registration.php" + ], + "exclude-from-classmap": [ + "/Test/" + ] + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-clover build/coverage/clover.xml && php Test/coverage-check.php build/coverage/clover.xml 90" + }, + "config": { + "sort-packages": true, + "allow-plugins": false, + "audit": { + "block-insecure": false + } + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..dc41588 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + Test/Unit/Block + + + Test/Unit/Controller + + + Test/Unit/Gateway + + + Test/Unit/Model + + + + + Block + Controller + Gateway + Model + + +