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
2 changes: 2 additions & 0 deletions src/shade/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
HTTPError,
RateLimitError,
ShadeError,
SignatureVerificationError,
)
from .models import Merchant, ShadeObject

Expand All @@ -35,6 +36,7 @@
"RateLimitError",
"ShadeClient",
"ShadeError",
"SignatureVerificationError",
"ShadeObject",
"SyncHTTPClient",
"config",
Expand Down
34 changes: 34 additions & 0 deletions src/shade/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,40 @@ def from_response(
return cls(message, status_code=404, response_body=response_body)


class SignatureVerificationError(ShadeError):
"""
Raised by ``Webhook.construct_event()`` when the HMAC-SHA256 signature in
the ``Shade-Signature`` header does not match the signature computed for
the payload.

Attributes:
header: The raw ``Shade-Signature`` header value as received.
"""

def __init__(
self,
message: str,
header: Optional[str] = None,
) -> None:
super().__init__(message)
self.header = header

@classmethod
def from_mismatch(cls, header: Optional[str] = None) -> "SignatureVerificationError":
"""Construct the error for a computed/received signature mismatch.

The expected signature is deliberately not accepted as an argument
here, so it can never end up in the exception message.
"""
message = (
"Webhook signature verification failed: the received signature "
"does not match the signature computed for this payload. This "
"usually means the webhook secret is incorrect, or the payload "
"was modified in transit."
)
return cls(message, header=header)


class NetworkError(ShadeError):
"""Raised when the SDK cannot complete a network request."""

Expand Down
39 changes: 39 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
NotFoundError,
RateLimitError,
ShadeError,
SignatureVerificationError,
)
from shade.errors import raise_for_invalid_request

Expand Down Expand Up @@ -69,6 +70,7 @@ def test_package_root_exports_error_classes():
assert shade.NetworkError is NetworkError
assert shade.NotFoundError is NotFoundError
assert shade.RateLimitError is RateLimitError
assert shade.SignatureVerificationError is SignatureVerificationError


def test_invalid_request_error_parses_param_from_body():
Expand Down Expand Up @@ -211,3 +213,40 @@ def test_not_found_error_invalid_json_body():

assert error.resource_type is None
assert error.resource_id is None


def test_signature_verification_error_is_shade_error():
error = SignatureVerificationError("bad signature", header="t=1,v1=abc")

assert isinstance(error, ShadeError)
assert str(error) == "bad signature"
assert error.header == "t=1,v1=abc"


def test_signature_verification_error_header_optional():
error = SignatureVerificationError("bad signature")

assert error.header is None


def test_signature_verification_error_from_mismatch_stores_header():
error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef")

assert isinstance(error, ShadeError)
assert error.header == "t=1,v1=deadbeef"


def test_signature_verification_error_from_mismatch_explains_causes():
error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef")

message = str(error)
assert "secret" in message
assert "payload" in message


def test_signature_verification_error_never_includes_expected_signature():
expected_signature = "supersecrethmacvalue"
error = SignatureVerificationError.from_mismatch(header="t=1,v1=deadbeef")

assert expected_signature not in str(error)
assert not hasattr(error, "expected_signature")
Loading