diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 37ccd44..6ffc446 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -21,9 +21,14 @@ jobs: - name: Install package run: | python -m pip install --upgrade pip - # Pre-generation the repo has no installable package yet; the verify - # tests import from src/ directly (PYTHONPATH below). - pip install -e . || echo "editable install unavailable (pre-generation); continuing" + # pyproject.toml arrives with the first Speakeasy generation. Once + # it exists, installation must succeed — a packaging regression + # (e.g. wheel omitting webhook verify) has to fail CI, not hide. + if [ -f pyproject.toml ]; then + pip install -e . + else + echo "pre-generation: no installable package yet; tests import from src/" + fi pip install pytest - name: Verify hand-written modules are present @@ -32,9 +37,18 @@ jobs: test -f test/signature-vectors.json test -f test/test_shared_vectors.py + - name: Verify installed package exposes webhook verify + if: hashFiles('pyproject.toml') != '' + # No PYTHONPATH: this must resolve from the installed distribution. + run: python -c "from convoy.utils.webhook import Webhook" + - name: Execute verify + shared vector tests - env: - # PEP 420 namespace packages: `convoy.utils.webhook` resolves from - # src/ before generation adds real __init__.py files. - PYTHONPATH: src - run: pytest test/test_webhook.py test/test_shared_vectors.py -q + run: | + if [ -f pyproject.toml ]; then + # Import from the installed distribution so packaging bugs surface. + pytest test/test_webhook.py test/test_shared_vectors.py -q + else + # PEP 420 namespace packages: resolve convoy.utils.webhook from + # src/ before generation adds real __init__.py files. + PYTHONPATH=src pytest test/test_webhook.py test/test_shared_vectors.py -q + fi diff --git a/setup.py b/setup.py deleted file mode 100644 index c141745..0000000 --- a/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Setup script for realpython-reader""" - -# Standard library imports -import pathlib - -# Third party imports -from setuptools import setup - -# The directory containing this file -HERE = pathlib.Path(__file__).resolve().parent - -# The text of the README file is used as a description -README = (HERE / "README.md").read_text() - -# This call to setup() does all the work -setup( - name="convoy-python", - version="1.0.0a0", - description="Python SDK for Convoy (Speakeasy-generated API client; hand-written webhook verify)", - url="https://github.com/frain-dev/convoy-python", - long_description=README, - long_description_content_type="text/markdown", - author="Frain Inc.", - author_email="info@frain.dev", - license="MIT", - classifiers=[ - "License :: OSI Approved :: MIT License", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - ], - packages=["convoy", "convoy.client", "convoy.api", "convoy.utils"], - include_package_data=True, - install_requires=["requests"], -) \ No newline at end of file diff --git a/src/convoy/utils/webhook.py b/src/convoy/utils/webhook.py index 6243f32..e9fcfdb 100644 --- a/src/convoy/utils/webhook.py +++ b/src/convoy/utils/webhook.py @@ -5,7 +5,7 @@ import json class InvalidTimestampError(Exception): - def __init__(self, *args: list) -> None: + def __init__(self, *args: str) -> None: self.message = args[0] @property @@ -14,7 +14,7 @@ def response(self): class InvalidSignature(Exception): - def __init__(self, *args: list) -> None: + def __init__(self, *args: str) -> None: self.message = args[0] @property @@ -59,9 +59,9 @@ def compare_hashes(self, hash1: str, hash2: str) -> bool: try: decoded1 = base64.b64decode(hash1) decoded2 = base64.b64decode(hash2) - except (ValueError, TypeError): + except (ValueError, TypeError) as exc: # A malformed signature is a mismatch, not a crash. - raise InvalidSignature("Invalid signature.") + raise InvalidSignature("Invalid signature.") from exc valid = hmac.compare_digest(decoded1, decoded2) if valid is False: raise InvalidSignature("Invalid signature.") @@ -100,6 +100,9 @@ def create_signature(self, payload: str) -> str: if self.encoding == "base64": sig = hmac.new(bytes(self.secret, "utf-8"), msg=bytes(encoded_payload, "utf-8"), digestmod=self.hash).digest() return base64.b64encode(sig).decode() + + # Fail closed instead of implicitly returning None. + raise InvalidSignature("Invalid encoding.") def create_advanced_signature(self, payload, timestamp=None) -> str: """Create signature for advanced webhooks (timestamp + payload) like Convoy does""" @@ -119,6 +122,9 @@ def create_advanced_signature(self, payload, timestamp=None) -> str: if self.encoding == "base64": sig = hmac.new(bytes(self.secret, "utf-8"), msg=bytes(signed_payload, "utf-8"), digestmod=self.hash).digest() return base64.b64encode(sig).decode() + + # Fail closed instead of implicitly returning None. + raise InvalidSignature("Invalid encoding.") def get_timestamp_and_signatures(self, signature): pairs = [sig.split("=", 1) for sig in signature.split(",")] @@ -126,8 +132,8 @@ def get_timestamp_and_signatures(self, signature): timestamp_pair = next((p for p in pairs if p[0].strip() == "t"), None) try: timestamp_int = int(timestamp_pair[1]) - except (TypeError, ValueError): - raise InvalidTimestampError("Invalid timestamp format") + except (TypeError, ValueError) as exc: + raise InvalidTimestampError("Invalid timestamp format") from exc timestamp = datetime.fromtimestamp(timestamp_int) signatures = [p[1] for p in pairs if p[0].strip() != "t" and len(p) == 2]