[IMP] payment_payulatam: support webhook notifications

task-2701097
opw-2627875

closes odoo/odoo#84581

X-original-commit: b82e6788921fc1f3b1ebeeb13eb79619416932f8
Signed-off-by: Antoine Vandevenne (anv) <anv@odoo.com>
This commit is contained in:
Hubert Van de Walle (huvw)
2022-02-16 09:40:11 +00:00
committed by Antoine Vandevenne (anv)
parent 90cf777a4d
commit b9d3527c46
6 changed files with 195 additions and 28 deletions
@@ -73,6 +73,21 @@ class BuckarooTest(BuckarooCommon, PaymentHttpCommon):
self._make_http_post_request(url, data=self.async_notification_data)
self.assertEqual(tx.state, 'done')
@mute_logger('odoo.addons.payment_buckaroo.controllers.main')
def test_webhook_notification_triggers_signature_check(self):
""" Test that receiving a webhook notification triggers a signature check. """
self.create_transaction('redirect')
url = self._build_url(BuckarooController._return_url)
with patch(
'odoo.addons.payment_buckaroo.controllers.main.BuckarooController'
'._verify_notification_signature'
) as signature_check_mock, patch(
'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
'._handle_notification_data'
):
self._make_http_post_request(url, data=self.async_notification_data)
self.assertEqual(signature_check_mock.call_count, 1)
def test_accept_notification_with_valid_signature(self):
""" Test the verification of a notification with a valid signature. """
tx = self.create_transaction('redirect')
+100 -2
View File
@@ -3,17 +3,115 @@
import logging
import pprint
from werkzeug.exceptions import Forbidden
from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.tools import consteq
_logger = logging.getLogger(__name__)
class PayuLatamController(http.Controller):
_return_url = '/payment/payulatam/return'
_webhook_url = '/payment/payulatam/webhook'
@http.route(_return_url, type='http', auth='public', methods=['GET'])
def payulatam_return(self, **data):
def payulatam_return_from_checkout(self, **data):
""" Process the notification data sent by PayU Latam after redirection from checkout.
See http://developers.payulatam.com/latam/en/docs/integrations/webcheckout-integration/response-page.html.
:param dict data: The notification data
"""
_logger.info("handling redirection from PayU Latam with data:\n%s", pprint.pformat(data))
request.env['payment.transaction'].sudo()._handle_notification_data('payulatam', data)
# Check the integrity of the notification
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
'payulatam', data
)
self._verify_notification_signature(data, tx_sudo)
# Handle the notification data
tx_sudo._handle_notification_data('payulatam', data)
return request.redirect('/payment/status')
@http.route(_webhook_url, type='http', auth='public', methods=['POST'], csrf=False)
def payulatam_webhook(self, **raw_data):
""" Process the notification data sent by PayU Latam to the webhook.
See http://developers.payulatam.com/latam/en/docs/integrations/webcheckout-integration/confirmation-page.html.
:param dict raw_data: The un-formatted notification data
:return: An empty string to acknowledge the notification
:rtype: str
"""
_logger.info(
"notification received from PayU Latam with data:\n%s", pprint.pformat(raw_data)
)
data = self._normalize_data_keys(raw_data)
try:
# Check the origin and integrity of the notification
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
'payulatam', data
)
self._verify_notification_signature(data, tx_sudo) # Use the normalized data.
# Handle the notification data
tx_sudo._handle_notification_data('payulatam', data)
except ValidationError: # Acknowledge the notification to avoid getting spammed
_logger.exception("unable to handle the notification data; skipping to acknowledge")
return ''
@staticmethod
def _normalize_data_keys(webhook_notification_data):
""" Reshape the webhook notification data to process them as redirect notification data.
:param dict webhook_notification_data: The webhook notification data
:return: The normalized notification data
:rtype: dict
"""
state_pol = webhook_notification_data.get('state_pol')
if state_pol == '4':
lap_transaction_state = 'APPROVED'
elif state_pol == '6':
lap_transaction_state = 'DECLINED'
elif state_pol == '5':
lap_transaction_state = 'EXPIRED'
else:
lap_transaction_state = f'INVALID state_pol {state_pol}'
return {
'lapTransactionState': lap_transaction_state,
'transactionState': webhook_notification_data.get('state_pol'),
'TX_VALUE': webhook_notification_data.get('value'),
'currency': webhook_notification_data.get('currency'),
'referenceCode': webhook_notification_data.get('reference_sale'),
'transactionId': webhook_notification_data.get('transaction_id'),
'message': webhook_notification_data.get('response_message_pol'),
'signature': webhook_notification_data.get('sign'),
}
@staticmethod
def _verify_notification_signature(notification_data, tx_sudo):
""" Check that the received signature matches the expected one.
:param dict notification_data: The notification data
:param recordset tx_sudo: The sudoed transaction referenced by the notification data, as a
`payment.transaction` record
:return: None
:raise: :class:`werkzeug.exceptions.Forbidden` if the signatures don't match
"""
# Retrieve the received signature from the data
received_signature = notification_data.get('signature')
if not received_signature:
_logger.warning("received notification with missing signature")
raise Forbidden()
# Compare the received signature with the expected signature computed from the data
expected_signature = tx_sudo.acquirer_id._payulatam_generate_sign(notification_data)
if not consteq(received_signature, expected_signature):
_logger.warning("received notification with invalid signature")
raise Forbidden()
@@ -63,6 +63,7 @@ class PaymentTransaction(models.Model):
api_url = 'https://checkout.payulatam.com/ppp-web-gateway-payu/' \
if self.acquirer_id.state == 'enabled' \
else 'https://sandbox.checkout.payulatam.com/ppp-web-gateway-payu/'
base_url = self.get_base_url()
payulatam_values = {
'merchantId': self.acquirer_id.payulatam_merchant_id,
'referenceCode': self.reference,
@@ -74,7 +75,8 @@ class PaymentTransaction(models.Model):
'accountId': self.acquirer_id.payulatam_account_id,
'buyerFullName': self.partner_name,
'buyerEmail': self.partner_email,
'responseUrl': urls.url_join(self.get_base_url(), PayuLatamController._return_url),
'responseUrl': urls.url_join(base_url, PayuLatamController._return_url),
'confirmationUrl': urls.url_join(base_url, PayuLatamController._webhook_url),
'api_url': api_url,
}
if self.acquirer_id.state != 'enabled':
@@ -91,40 +93,19 @@ class PaymentTransaction(models.Model):
:param dict notification_data: The notification data sent by the provider
:return: The transaction if found
:rtype: recordset of `payment.transaction`
:raise: ValidationError if inconsistent data were received
:raise: ValidationError if the data match no transaction
:raise: ValidationError if the signature can not be verified
"""
tx = super()._get_tx_from_notification_data(provider, notification_data)
if provider != 'payulatam' or len(tx) == 1:
return tx
reference = notification_data.get('referenceCode')
sign = notification_data.get('signature')
if not reference or not sign:
raise ValidationError(
"PayU Latam: " + _(
"Received data with missing reference (%(ref)s) or sign (%(sign)s).",
ref=reference, sign=sign
)
)
tx = self.search([('reference', '=', reference), ('provider', '=', 'payulatam')])
if not tx:
raise ValidationError(
"PayU Latam: " + _("No transaction found matching reference %s.", reference)
)
# Verify signature
sign_check = tx.acquirer_id._payulatam_generate_sign(notification_data, incoming=True)
if not hmac.compare_digest(sign_check, sign):
raise ValidationError(
"PayU Latam: " + _(
"Invalid sign: received %(sign)s, computed %(check)s.",
sign=sign, check=sign_check
)
)
return tx
def _process_notification_data(self, notification_data):
+10
View File
@@ -18,3 +18,13 @@ class PayULatamCommon(PaymentCommon):
# Override default values
cls.acquirer = cls.payulatam
cls.currency = cls.currency_euro
cls.async_notification_data = {
'currency': cls.currency.name,
'reference_sale': cls.reference,
'response_message_pol': 'APPROVED',
'sign': '6b4728ddb01317af58f92b8accdb4a42',
'state_pol': '4',
'transaction_id': '7008bc34-8258-4857-b866-7d4d7982bd73',
'value': str(cls.amount)
}
@@ -1,19 +1,23 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import patch
from freezegun import freeze_time
from werkzeug.exceptions import Forbidden
from odoo.exceptions import ValidationError
from odoo.fields import Command
from odoo.tests import tagged
from odoo.tools import mute_logger
from .common import PayULatamCommon
from ..controllers.main import PayuLatamController
from ..models.payment_acquirer import SUPPORTED_CURRENCIES
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
from odoo.addons.payment_payulatam.controllers.main import PayuLatamController
from odoo.addons.payment_payulatam.models.payment_acquirer import SUPPORTED_CURRENCIES
from odoo.addons.payment_payulatam.tests.common import PayULatamCommon
@tagged('post_install', '-at_install')
class PayULatamTest(PayULatamCommon):
class PayULatamTest(PayULatamCommon, PaymentHttpCommon):
def test_compatibility_with_supported_currencies(self):
""" Test that the PayULatam acquirer is compatible with all supported currencies. """
@@ -67,6 +71,7 @@ class PayULatamTest(PayULatamCommon):
'buyerEmail': self.partner.email,
'buyerFullName': self.partner.name,
'responseUrl': self._build_url(PayuLatamController._return_url),
'confirmationUrl': self._build_url(PayuLatamController._webhook_url),
'test': str(1), # testing is always performed in test mode
}
expected_values['signature'] = self.payulatam._payulatam_generate_sign(
@@ -147,6 +152,63 @@ class PayULatamTest(PayULatamCommon):
self.assertEqual(tx.state, 'done', 'Payulatam: wrong state after receiving a valid pending notification')
self.assertEqual(tx.acquirer_reference, 'b232989a-4aa8-42d1-bace-153236eee791', 'Payulatam: wrong txn_id after receiving a valid pending notification')
@mute_logger('odoo.addons.payment_payulatam.controllers.main')
def test_webhook_notification_confirms_transaction(self):
""" Test the processing of a webhook notification. """
tx = self.create_transaction('redirect')
url = self._build_url(PayuLatamController._webhook_url)
with patch(
'odoo.addons.payment_payulatam.controllers.main.PayuLatamController'
'._verify_notification_signature'
):
self._make_http_post_request(url, data=self.async_notification_data)
self.assertEqual(tx.state, 'done')
@mute_logger('odoo.addons.payment_payulatam.controllers.main')
def test_webhook_notification_triggers_signature_check(self):
""" Test that receiving a webhook notification triggers a signature check. """
self.create_transaction('redirect')
url = self._build_url(PayuLatamController._webhook_url)
with patch(
'odoo.addons.payment_payulatam.controllers.main.PayuLatamController'
'._verify_notification_signature'
) as signature_check_mock, patch(
'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
'._handle_notification_data'
):
self._make_http_post_request(url, data=self.async_notification_data)
self.assertEqual(signature_check_mock.call_count, 1)
def test_accept_notification_with_valid_signature(self):
""" Test the verification of a notification with a valid signature. """
tx = self.create_transaction('redirect')
payload = PayuLatamController._normalize_data_keys(self.async_notification_data)
self._assert_does_not_raise(
Forbidden, PayuLatamController._verify_notification_signature, payload, tx
)
@mute_logger('odoo.addons.payment_payulatam.controllers.main')
def test_reject_notification_with_missing_signature(self):
""" Test the verification of a notification with a missing signature. """
tx = self.create_transaction('redirect')
payload = PayuLatamController._normalize_data_keys(
dict(self.async_notification_data, sign=None)
)
self.assertRaises(
Forbidden, PayuLatamController._verify_notification_signature, payload, tx
)
@mute_logger('odoo.addons.payment_payulatam.controllers.main')
def test_reject_notification_with_invalid_signature(self):
""" Test the verification of a notification with an invalid signature. """
tx = self.create_transaction('redirect')
payload = PayuLatamController._normalize_data_keys(
dict(self.async_notification_data, sign='dummy')
)
self.assertRaises(
Forbidden, PayuLatamController._verify_notification_signature, payload, tx
)
def test_payulatam_neutralize(self):
self.env['payment.acquirer']._neutralize()
@@ -18,6 +18,7 @@
<input type="hidden" name="buyerFullName" t-att-value="buyerFullName"/>
<input type="hidden" name="buyerEmail" t-att-value="buyerEmail"/>
<input type="hidden" name="responseUrl" t-att-value="responseUrl"/>
<input type="hidden" name="confirmationUrl" t-att-value="confirmationUrl"/>
</form>
</template>