Skip to content

Commit

Permalink
Merge pull request #3 from HonzaSaibic/fix_signature_with_new_custome…
Browse files Browse the repository at this point in the history
…r_data

Fix the signature string with a new customer data sent to oneclick and payment method
  • Loading branch information
HonzaSaibic authored Jul 24, 2024
2 parents 21bb597 + 95d7a9a commit 55d8d47
Show file tree
Hide file tree
Showing 3 changed files with 225 additions and 30 deletions.
10 changes: 8 additions & 2 deletions pycsob/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ def payment_init(self, order_no, total_amount, return_url, description, customer
Initialize transaction, sum of cart items must be equal to total amount
If cart is None, we create it for you from total_amount and description values.
The payload structure must follow the signature structure order. Please follow the documentation
https://github.com/csob/platebnibrana/wiki/Podpis-po%C5%BEadavku-a-ov%C4%9B%C5%99en%C3%AD-podpisu-odpov%C4%9Bdi
Cart example::
cart = [
Expand Down Expand Up @@ -124,11 +127,11 @@ def payment_init(self, order_no, total_amount, return_url, description, customer
('payMethod', 'card'),
('totalAmount', total_amount),
('currency', currency),
('customer', utils.convert_keys_to_camel_case(customer_data)),
('closePayment', close_payment),
('returnUrl', return_url),
('returnMethod', return_method),
('cart', cart),
('customer', utils.convert_keys_to_camel_case(customer_data)),
('merchantData', merchant_data),
('customerId', customer_id),
('language', language),
Expand Down Expand Up @@ -225,19 +228,22 @@ def oneclick_init(self, orig_pay_id, order_no, total_amount, customer_data, curr
"""
Initialize one-click payment. Before this, you need to call payment_init(..., pay_operation='oneclickPayment')
It will create payment template for you. Use pay_id returned from payment_init as orig_pay_id in this method.
The payload structure must follow the signature structure order. Please follow the documentation
https://github.com/csob/platebnibrana/wiki/Podpis-po%C5%BEadavku-a-ov%C4%9B%C5%99en%C3%AD-podpisu-odpov%C4%9Bdi
"""

payload = utils.mk_payload(self.key, pairs=(
('merchantId', self.merchant_id),
('origPayId', orig_pay_id),
('orderNo', str(order_no)),
('customer', utils.convert_keys_to_camel_case(customer_data)),
('dttm', utils.dttm()),
('totalAmount', total_amount),
('currency', currency),
('description', description),
('returnUrl', return_url),
('returnMethod', return_method),
('customer', utils.convert_keys_to_camel_case(customer_data)),
('clientInitiated', client_initiated),
))
url = utils.mk_url(base_url=self.base_url, endpoint_url='oneclick/init')
Expand Down
32 changes: 30 additions & 2 deletions pycsob/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ def mk_msg_for_sign(payload):
for one in payload['cart']:
cart_msg.extend(one.values())
payload['cart'] = '|'.join(map(str_or_jsbool, cart_msg))
if payload.get('customer') not in conf.EMPTY_VALUES:
payload['customer'] = get_customer_data_signature_message(payload['customer'])
msg = '|'.join(map(str_or_jsbool, payload.values()))
return msg.encode('utf-8')

Expand Down Expand Up @@ -134,10 +136,10 @@ def to_camel_case(value: str) -> str:
the original value.
"""
first_word, *other_words = value.split('_')
return "".join([first_word.lower(), *map(str.title, other_words)]) if other_words else first_word
return ''.join([first_word.lower(), *map(str.title, other_words)]) if other_words else first_word


T = TypeVar("T", list[Any], dict[str, Any])
T = TypeVar('T', list[Any], dict[str, Any])


def convert_keys_to_camel_case(data: T) -> T:
Expand Down Expand Up @@ -165,3 +167,29 @@ def convert_keys_to_camel_case(data: T) -> T:
else:
converted_dict[key] = value
return converted_dict


def get_customer_data_signature_message(customer_data: dict[str, Any]) -> str:
"""
Returns signature string from customer data used to sign the request.
For more information follow the API documentation
https://github.com/csob/platebnibrana/wiki/Podpis-po%C5%BEadavku-a-ov%C4%9B%C5%99en%C3%AD-podpisu-odpov%C4%9Bdi
"""

def get_joined_values(data: dict[str, Any], keys: list) -> str:
"""
Args:
data: payload customer data
keys: list with ordered keys
"""
return '|'.join(str_or_jsbool(data[key]) for key in keys if key in data)

customer_keys = ['name', 'email', 'mobilePhone']
account_keys = ['createdAt', 'changedAt']
login_keys = ['auth', 'authAt']

customer_msg = get_joined_values(customer_data, customer_keys)
account_msg = get_joined_values(customer_data.get('account', {}), account_keys)
login_msg = get_joined_values(customer_data.get('login', {}), login_keys)

return '|'.join(filter(None, (customer_msg, account_msg, login_msg)))
213 changes: 187 additions & 26 deletions tests_pycsob/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from freezegun import freeze_time
from unittest import TestCase
from requests.exceptions import HTTPError, ConnectionError
from pycsob.utils import convert_keys_to_camel_case, to_camel_case
from pycsob.utils import convert_keys_to_camel_case, to_camel_case, get_customer_data_signature_message, mk_msg_for_sign

from pycsob import conf, utils
from pycsob.client import CsobClient
Expand Down Expand Up @@ -277,42 +277,42 @@ def test_connection_exceptions_should_be_caught_and_be_handled(self):

class CsobUtilsTests(TestCase):
def test_to_camel_case_should_convert_string_to_camel_case(self):
assert to_camel_case("") == ""
assert to_camel_case("THIS_IS_SNAKE_CASE") == "thisIsSnakeCase"
assert to_camel_case("thisIsSnakeCase") == "thisIsSnakeCase"
assert to_camel_case('') == ''
assert to_camel_case('THIS_IS_SNAKE_CASE') == 'thisIsSnakeCase'
assert to_camel_case('thisIsSnakeCase') == 'thisIsSnakeCase'

def test_convert_keys_to_camel_case_should_convert_dict_keys_to_camel_case(self):
customer_data = {
"name": "Petr Novak",
"mobile_phone": "+420.735293123",
"addressInfo": {
"address_count": 2,
"addresses": [
'name': 'Petr Novak',
'mobile_phone': '+420.735293123',
'addressInfo': {
'address_count': 2,
'addresses': [
{
"street_address": "Malkovskeho",
"types": [],
'street_address': 'Malkovskeho',
'types': [],
},
{
"street_address": "Holesovice",
"types": ["billing"],
'street_address': 'Holesovice',
'types': ['billing'],
},
]
},
}

assert convert_keys_to_camel_case(customer_data) == {
"name": "Petr Novak",
"mobilePhone": "+420.735293123",
"addressInfo": {
"addressCount": 2,
"addresses": [
'name': 'Petr Novak',
'mobilePhone': '+420.735293123',
'addressInfo': {
'addressCount': 2,
'addresses': [
{
"streetAddress": "Malkovskeho",
"types": [],
'streetAddress': 'Malkovskeho',
'types': [],
},
{
"streetAddress": "Holesovice",
"types": ["billing"],
'streetAddress': 'Holesovice',
'types': ['billing'],
},
]
},
Expand All @@ -321,15 +321,15 @@ def test_convert_keys_to_camel_case_should_convert_dict_keys_to_camel_case(self)
def test_convert_keys_to_camel_case_should_convert_dict_keys_to_camel_case_even_inside_a_list(self):
list_data = [
{
"customer_name": "test",
'customer_name': 'test',
1:1,
}
]
assert convert_keys_to_camel_case(list_data) == [{'customerName': 'test', 1:1}]

def test_convert_keys_to_camel_case_should_not_convert_list_of_strings_only(self):
list_data = ["customer_name", 1, None]
assert convert_keys_to_camel_case(list_data) == ["customer_name", 1, None]
list_data = ['customer_name', 1, None]
assert convert_keys_to_camel_case(list_data) == ['customer_name', 1, None]

def test_convert_keys_to_camel_case_should_log_error_for_not_string_keys(self):
with self.assertLogs() as logs:
Expand All @@ -338,8 +338,169 @@ def test_convert_keys_to_camel_case_should_log_error_for_not_string_keys(self):
"Incorrect value type '<class 'NoneType'>' during conversion to camcel case. String expected."
)

assert convert_keys_to_camel_case({1: "test"}) == {1: "test"}
assert convert_keys_to_camel_case({1: 'test'}) == {1: 'test'}
assert logs.records[1].message == (
"Incorrect value type '<class 'int'>' during conversion to camcel case. String expected."
)

def test_get_customer_data_signature_message_should_return_correct_string_format(self):
customer = {
'name':'Jan Novák',
'email':'jan.novak@example.com',
'mobilePhone':'+420.800300300',
'account': {
'createdAt':'2022-01-12T12:10:37+01:00',
'changedAt':'2022-01-15T15:10:12+01:00'
},
'login': {
'auth':'account',
'authAt':'2022-01-25T13:10:03+01:00'
}
}

assert get_customer_data_signature_message(customer) == (
'Jan Novák|jan.novak@example.com|+420.800300300'
'|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00'
'|account|2022-01-25T13:10:03+01:00'
)

def test_get_customer_data_signature_message_should_be_able_to_skip_missing_values(self):
# missing account
customer = {
'name':'Jan Novák',
'email':'jan.novak@example.com',
'mobilePhone':'+420.800300300',
'login': {
'auth':'account',
'authAt':'2022-01-25T13:10:03+01:00'
}
}

assert get_customer_data_signature_message(customer) == (
'Jan Novák|jan.novak@example.com|+420.800300300|account|2022-01-25T13:10:03+01:00'
)

# missing login
customer = {
'name':'Jan Novák',
'email':'jan.novak@example.com',
'mobilePhone':'+420.800300300',
'account': {
'createdAt':'2022-01-12T12:10:37+01:00',
'changedAt':'2022-01-15T15:10:12+01:00'
},
}
assert get_customer_data_signature_message(customer) == (
'Jan Novák|jan.novak@example.com|+420.800300300|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00'
)

# missing customer email
customer = {
'name':'Jan Novák',
'mobilePhone':'+420.800300300',
}
assert get_customer_data_signature_message(customer) == (
'Jan Novák|+420.800300300'
)

# missing customer personal data
customer = {
'account': {
'createdAt':'2022-01-12T12:10:37+01:00',
}
}
assert get_customer_data_signature_message(customer) == (
'2022-01-12T12:10:37+01:00'
)

def test_get_customer_data_signature_message_should_ignore_invalid_keys(self):
customer = {
'name':'Jan Novák',
'email':'jan.novak@example.com',
'mobilePhone':'+420.800300300',
'nonsense': 'Test',
'account': {
'createdAt':'2022-01-12T12:10:37+01:00',
'changedAt':'2022-01-15T15:10:12+01:00',
'nonsense': 'Test',
},
'login': {
'auth':'account',
'authAt':'2022-01-25T13:10:03+01:00',
'nonsense': 'Test',
}
}
assert get_customer_data_signature_message(customer) == (
'Jan Novák|jan.novak@example.com|+420.800300300'
'|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00'
'|account|2022-01-25T13:10:03+01:00'
)

def test_get_customer_data_signature_message_should_keep_correct_value_order(self):
customer = {
'mobilePhone':'+420.800300300',
'email':'jan.novak@example.com',
'name':'Jan Novák',
'login': {
'authAt':'2022-01-25T13:10:03+01:00',
'auth':'account',
},
'account': {
'changedAt':'2022-01-15T15:10:12+01:00',
'createdAt':'2022-01-12T12:10:37+01:00',
},
}

assert get_customer_data_signature_message(customer) == (
'Jan Novák|jan.novak@example.com|+420.800300300'
'|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00'
'|account|2022-01-25T13:10:03+01:00'
)

def test_mk_msg_for_sign_should_return_correctly_ordered_message(self):
payload = {
'merchantId':'M1MIPS0000',
'orderNo':'5547',
'dttm':'20220125131559',
'payOperation':'payment',
'payMethod':'card',
'totalAmount':123400,
'currency':'CZK',
'closePayment': True,
'returnUrl':'https://shop.example.com/return',
'returnMethod':'POST',
'cart':[
{
'name': 'Wireless headphones',
'quantity': 1,
'amount': 123400
},
{
'name': 'Shipping',
'quantity': 1,
'amount': 0,
'description': 'DPL'
}
],
'customer': {
'name':'Jan Novák',
'email':'jan.novak@example.com',
'mobilePhone':'+420.800300300',
'account': {
'createdAt':'2022-01-12T12:10:37+01:00',
'changedAt':'2022-01-15T15:10:12+01:00'
},
'login': {
'auth':'account',
'authAt':'2022-01-25T13:10:03+01:00'
}
},
}
expected_message = (
'M1MIPS0000|5547|20220125131559|payment|card|123400|CZK|true|https://shop.example.com/return|POST'
'|Wireless headphones|1|123400|Shipping|1|0|DPL'
'|Jan Novák|jan.novak@example.com|+420.800300300'
'|2022-01-12T12:10:37+01:00|2022-01-15T15:10:12+01:00'
'|account|2022-01-25T13:10:03+01:00'
).encode()
assert mk_msg_for_sign(payload) == expected_message

0 comments on commit 55d8d47

Please sign in to comment.