Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/multisafepay/api/base/abstract_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

# See the DISCLAIMER.md file for disclaimer details.

import urllib.parse

from multisafepay.client.client import Client

Expand All @@ -29,3 +30,19 @@ def __init__(self: "AbstractManager", client: Client) -> None:

"""
self.client = client

@staticmethod
def encode_path_segment(segment: str) -> str:
"""
URL encode a path segment to be safely included in a URL.

Parameters
----------
segment (str): The path segment to encode

Returns
-------
str: The URL encoded path segment

"""
return urllib.parse.quote(str(segment), safe="")
3 changes: 2 additions & 1 deletion src/multisafepay/api/paths/capture/capture_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ def capture_reservation_cancel(
"""
json_data = json.dumps(capture_request.dict())
encoded_order_id = self.encode_path_segment(order_id)
response = self.client.create_patch_request(
f"json/capture/{order_id}",
f"json/capture/{encoded_order_id}",
request_body=json_data,
)
args: dict = {
Expand Down
3 changes: 2 additions & 1 deletion src/multisafepay/api/paths/gateways/gateway_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,9 @@ def get_by_code(
options = {}
options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS}

encoded_gateway_code = self.encode_path_segment(gateway_code)
response = self.client.create_get_request(
f"json/gateways/{gateway_code}",
f"json/gateways/{encoded_gateway_code}",
options,
)
args: dict = {
Expand Down
3 changes: 2 additions & 1 deletion src/multisafepay/api/paths/issuers/issuer_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ def get_issuers_by_gateway_code(
if gateway_code not in ALLOWED_GATEWAY_CODES:
raise InvalidArgumentException("Gateway code is not allowed")

encoded_gateway_code = self.encode_path_segment(gateway_code)
response = self.client.create_get_request(
f"json/issuers/{gateway_code}",
f"json/issuers/{encoded_gateway_code}",
)
args: dict = {
**response.dict(),
Expand Down
13 changes: 9 additions & 4 deletions src/multisafepay/api/paths/orders/order_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ def get(self: "OrderManager", order_id: str) -> CustomApiResponse:
CustomApiResponse: The custom API response containing the order data.

"""
endpoint = f"json/orders/{order_id}"
encoded_order_id = self.encode_path_segment(order_id)
endpoint = f"json/orders/{encoded_order_id}"
context = {"order_id": order_id}
response: ApiResponse = self.client.create_get_request(
endpoint,
Expand Down Expand Up @@ -152,8 +153,9 @@ def update(

"""
json_data = json.dumps(update_request.to_dict())
encoded_order_id = self.encode_path_segment(order_id)
response = self.client.create_patch_request(
f"json/orders/{order_id}",
f"json/orders/{encoded_order_id}",
request_body=json_data,
)
args: dict = {
Expand Down Expand Up @@ -181,9 +183,10 @@ def capture(

"""
json_data = json.dumps(capture_request.to_dict())
encoded_order_id = self.encode_path_segment(order_id)

response = self.client.create_post_request(
f"json/orders/{order_id}/capture",
f"json/orders/{encoded_order_id}/capture",
request_body=json_data,
)
args: dict = {
Expand Down Expand Up @@ -221,8 +224,9 @@ def refund(

"""
json_data = json.dumps(request_refund.to_dict())
encoded_order_id = self.encode_path_segment(order_id)
response = self.client.create_post_request(
f"json/orders/{order_id}/refunds",
f"json/orders/{encoded_order_id}/refunds",
request_body=json_data,
)
args: dict = {
Expand Down Expand Up @@ -267,6 +271,7 @@ def refund_by_item(
quantity,
)

# Encode the order_id before calling refund
return self.refund(order.order_id, request_refund)

@staticmethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ def get_by_gateway_code(
if options is None:
options = {}
options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS}
encoded_gateway_code = self.encode_path_segment(gateway_code)
response = self.client.create_get_request(
f"json/payment-methods/{gateway_code}",
f"json/payment-methods/{encoded_gateway_code}",
options,
)
args: dict = {
Expand Down
11 changes: 8 additions & 3 deletions src/multisafepay/api/paths/recurring/recurring_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ def get_list(
CustomApiResponse: The response containing the list of tokens.

"""
encoded_reference = self.encode_path_segment(reference)
response: ApiResponse = self.client.create_get_request(
f"json/recurring/{reference}",
f"json/recurring/{encoded_reference}",
)
args: dict = {
**response.dict(),
Expand Down Expand Up @@ -109,8 +110,10 @@ def get(
CustomApiResponse: The response containing the token data.

"""
encoded_reference = self.encode_path_segment(reference)
encoded_token = self.encode_path_segment(token)
response = self.client.create_get_request(
f"json/recurring/{reference}/token/{token}",
f"json/recurring/{encoded_reference}/token/{encoded_token}",
)
args: dict = {
**response.dict(),
Expand Down Expand Up @@ -144,8 +147,10 @@ def delete(
CustomApiResponse: The response after deleting the token.

"""
encoded_reference = self.encode_path_segment(reference)
encoded_token = self.encode_path_segment(token)
response = self.client.create_delete_request(
f"json/recurring/{reference}/remove/{token}",
f"json/recurring/{encoded_reference}/remove/{encoded_token}",
)
args: dict = {
**response.dict(),
Expand Down
163 changes: 163 additions & 0 deletions tests/multisafepay/unit/api/base/test_abstract_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Copyright (c) MultiSafepay, Inc. All rights reserved.

# This file is licensed under the Open Software License (OSL) version 3.0.
# For a copy of the license, see the LICENSE.txt file in the project root.

# See the DISCLAIMER.md file for disclaimer details.


from multisafepay.api.base.abstract_manager import AbstractManager


def test_encode_path_segment_with_normal_string():
"""Test encoding a normal string without special characters."""
result = AbstractManager.encode_path_segment("normal_string")
assert result == "normal_string"


def test_encode_path_segment_with_spaces():
"""Test encoding a string with spaces."""
result = AbstractManager.encode_path_segment("hello world")
assert result == "hello%20world"


def test_encode_path_segment_with_special_characters():
"""Test encoding a string with various special characters."""
result = AbstractManager.encode_path_segment("hello@world#test")
assert result == "hello%40world%23test"


def test_encode_path_segment_with_forward_slash():
"""Test encoding a string with forward slashes."""
result = AbstractManager.encode_path_segment("path/to/resource")
assert result == "path%2Fto%2Fresource"


def test_encode_path_segment_with_question_mark():
"""Test encoding a string with question marks."""
result = AbstractManager.encode_path_segment("query?param=value")
assert result == "query%3Fparam%3Dvalue"


def test_encode_path_segment_with_ampersand():
"""Test encoding a string with ampersands."""
result = AbstractManager.encode_path_segment("param1&param2")
assert result == "param1%26param2"


def test_encode_path_segment_with_equals_sign():
"""Test encoding a string with equals signs."""
result = AbstractManager.encode_path_segment("key=value")
assert result == "key%3Dvalue"


def test_encode_path_segment_with_percentage_sign():
"""Test encoding a string with percentage signs."""
result = AbstractManager.encode_path_segment("discount%off")
assert result == "discount%25off"


def test_encode_path_segment_with_plus_sign():
"""Test encoding a string with plus signs."""
result = AbstractManager.encode_path_segment("one+two")
assert result == "one%2Btwo"


def test_encode_path_segment_with_unicode_characters():
"""Test encoding a string with Unicode characters."""
result = AbstractManager.encode_path_segment("café")
assert result == "caf%C3%A9"


def test_encode_path_segment_with_emoji():
"""Test encoding a string with emoji characters."""
result = AbstractManager.encode_path_segment("hello😊world")
assert result == "hello%F0%9F%98%8Aworld"


def test_encode_path_segment_with_empty_string():
"""Test encoding an empty string."""
result = AbstractManager.encode_path_segment("")
assert result == ""


def test_encode_path_segment_with_only_special_characters():
"""Test encoding a string with only special characters."""
result = AbstractManager.encode_path_segment("!@#$%^&*()")
assert result == "%21%40%23%24%25%5E%26%2A%28%29"


def test_encode_path_segment_with_numbers():
"""Test encoding a string with numbers."""
result = AbstractManager.encode_path_segment("123456")
assert result == "123456"


def test_encode_path_segment_with_mixed_alphanumeric():
"""Test encoding a string with mixed alphanumeric characters."""
result = AbstractManager.encode_path_segment("abc123XYZ")
assert result == "abc123XYZ"


def test_encode_path_segment_with_hyphen_and_underscore():
"""Test encoding a string with hyphens and underscores (safe characters)."""
result = AbstractManager.encode_path_segment("test-value_123")
assert result == "test-value_123"


def test_encode_path_segment_with_period_and_tilde():
"""Test encoding a string with periods and tildes (safe characters)."""
result = AbstractManager.encode_path_segment("file.txt~backup")
assert result == "file.txt~backup"


def test_encode_path_segment_with_integer_input():
"""Test encoding an integer input (should be converted to string)."""
result = AbstractManager.encode_path_segment(12345)
assert result == "12345"


def test_encode_path_segment_with_float_input():
"""Test encoding a float input (should be converted to string)."""
result = AbstractManager.encode_path_segment(123.45)
assert result == "123.45"


def test_encode_path_segment_with_none_input():
"""Test encoding None input (should be converted to string)."""
result = AbstractManager.encode_path_segment(None)
assert result == "None"


def test_encode_path_segment_preserves_unreserved_characters():
"""Test that unreserved characters are not encoded."""
# RFC 3986 unreserved characters: ALPHA / DIGIT / "-" / "." / "_" / "~"
unreserved = (
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)
result = AbstractManager.encode_path_segment(unreserved)
assert result == unreserved


def test_encode_path_segment_encodes_reserved_characters():
"""Test that reserved characters are properly encoded."""
# Some RFC 3986 reserved characters
reserved = ":/?#[]@!$&'()*+,;="
result = AbstractManager.encode_path_segment(reserved)
# All characters should be encoded since safe="" is used
assert ":" not in result
assert "/" not in result
assert "?" not in result
assert "#" not in result
assert "@" not in result
assert "!" not in result
assert "$" not in result
assert "&" not in result
assert "'" not in result
assert "(" not in result
assert ")" not in result
assert "*" not in result
assert "+" not in result
assert "," not in result
assert ";" not in result
assert "=" not in result