kalourmade-sailup 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kalourmade_sailup-0.1.0/PKG-INFO +9 -0
- kalourmade_sailup-0.1.0/pyproject.toml +21 -0
- kalourmade_sailup-0.1.0/setup.cfg +4 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup/__init__.py +27 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup/client.py +13 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup/error_mapping.py +16 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup/exceptions.py +31 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup/http.py +44 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup/models.py +52 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup/sms.py +29 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup/webhooks.py +26 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup.egg-info/PKG-INFO +9 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup.egg-info/SOURCES.txt +20 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup.egg-info/dependency_links.txt +1 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup.egg-info/requires.txt +4 -0
- kalourmade_sailup-0.1.0/src/kalourmade_sailup.egg-info/top_level.txt +1 -0
- kalourmade_sailup-0.1.0/tests/test_client.py +8 -0
- kalourmade_sailup-0.1.0/tests/test_error_mapping.py +36 -0
- kalourmade_sailup-0.1.0/tests/test_exceptions.py +26 -0
- kalourmade_sailup-0.1.0/tests/test_models.py +44 -0
- kalourmade_sailup-0.1.0/tests/test_sms.py +70 -0
- kalourmade_sailup-0.1.0/tests/test_webhooks.py +47 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kalourmade-sailup"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Official Python SDK for the Sailup SMS gateway"
|
|
5
|
+
requires-python = ">=3.9"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.24",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
dev = [
|
|
13
|
+
"pytest>=7.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools>=68", "wheel"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .client import SailupClient
|
|
2
|
+
from .exceptions import (
|
|
3
|
+
ApiError,
|
|
4
|
+
AuthenticationError,
|
|
5
|
+
InvalidSignatureError,
|
|
6
|
+
NetworkError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
SailupError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
)
|
|
11
|
+
from .models import DeliveryStatus, Message, Page
|
|
12
|
+
from .webhooks import Webhooks
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SailupClient",
|
|
16
|
+
"Webhooks",
|
|
17
|
+
"Message",
|
|
18
|
+
"Page",
|
|
19
|
+
"DeliveryStatus",
|
|
20
|
+
"ApiError",
|
|
21
|
+
"AuthenticationError",
|
|
22
|
+
"InvalidSignatureError",
|
|
23
|
+
"NetworkError",
|
|
24
|
+
"RateLimitError",
|
|
25
|
+
"SailupError",
|
|
26
|
+
"ValidationError",
|
|
27
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .http import HttpxTransport
|
|
2
|
+
from .sms import SmsResource
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SailupClient:
|
|
6
|
+
def __init__(
|
|
7
|
+
self,
|
|
8
|
+
api_key: str,
|
|
9
|
+
base_url: str = "https://api.sailup.io/v1",
|
|
10
|
+
timeout: float = 30.0,
|
|
11
|
+
):
|
|
12
|
+
transport = HttpxTransport(api_key, base_url, timeout)
|
|
13
|
+
self.sms = SmsResource(transport)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .exceptions import ApiError, AuthenticationError, RateLimitError, ValidationError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def raise_for_status(status: int, raw_body: str) -> None:
|
|
5
|
+
if status < 400:
|
|
6
|
+
return
|
|
7
|
+
|
|
8
|
+
message = f"Sailup API error (status {status})"
|
|
9
|
+
|
|
10
|
+
if status == 401:
|
|
11
|
+
raise AuthenticationError(message)
|
|
12
|
+
if status == 429:
|
|
13
|
+
raise RateLimitError(message)
|
|
14
|
+
if status in (400, 422):
|
|
15
|
+
raise ValidationError(message)
|
|
16
|
+
raise ApiError(status, raw_body, message)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class SailupError(Exception):
|
|
2
|
+
"""Base exception for all Sailup SDK errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthenticationError(SailupError):
|
|
6
|
+
"""Raised on 401 responses."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ValidationError(SailupError):
|
|
10
|
+
"""Raised on 400/422 responses."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RateLimitError(SailupError):
|
|
14
|
+
"""Raised on 429 responses."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ApiError(SailupError):
|
|
18
|
+
"""Raised on other 4xx/5xx responses."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, status_code: int, raw_body: str, message: str):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
self.raw_body = raw_body
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NetworkError(SailupError):
|
|
27
|
+
"""Raised when the request could not reach Sailup."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidSignatureError(SailupError):
|
|
31
|
+
"""Raised when webhook signature verification fails."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from typing import Optional, Protocol
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .exceptions import NetworkError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Transport(Protocol):
|
|
9
|
+
def request(
|
|
10
|
+
self,
|
|
11
|
+
method: str,
|
|
12
|
+
path: str,
|
|
13
|
+
query: Optional[dict] = None,
|
|
14
|
+
json_body: Optional[dict] = None,
|
|
15
|
+
) -> tuple[int, str]:
|
|
16
|
+
...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HttpxTransport:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: str,
|
|
23
|
+
base_url: str = "https://api.sailup.io/v1",
|
|
24
|
+
timeout: float = 30.0,
|
|
25
|
+
):
|
|
26
|
+
self._client = httpx.Client(
|
|
27
|
+
base_url=base_url,
|
|
28
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def request(
|
|
33
|
+
self,
|
|
34
|
+
method: str,
|
|
35
|
+
path: str,
|
|
36
|
+
query: Optional[dict] = None,
|
|
37
|
+
json_body: Optional[dict] = None,
|
|
38
|
+
) -> tuple[int, str]:
|
|
39
|
+
try:
|
|
40
|
+
response = self._client.request(method, path, params=query, json=json_body)
|
|
41
|
+
except httpx.HTTPError as exc:
|
|
42
|
+
raise NetworkError(str(exc)) from exc
|
|
43
|
+
|
|
44
|
+
return response.status_code, response.text
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DeliveryStatus(str, Enum):
|
|
7
|
+
PENDING = "pending"
|
|
8
|
+
DELIVERED = "delivered"
|
|
9
|
+
FAILED = "failed"
|
|
10
|
+
EXPIRED = "expired"
|
|
11
|
+
REJECTED = "rejected"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Message:
|
|
16
|
+
id: str
|
|
17
|
+
to: List[str]
|
|
18
|
+
sender: Optional[str]
|
|
19
|
+
body: str
|
|
20
|
+
quantity: int
|
|
21
|
+
delivery_status: DeliveryStatus
|
|
22
|
+
created_at: str
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def from_dict(data: dict) -> "Message":
|
|
26
|
+
to = data["to"] if isinstance(data["to"], list) else [data["to"]]
|
|
27
|
+
return Message(
|
|
28
|
+
id=data["id"],
|
|
29
|
+
to=to,
|
|
30
|
+
sender=data.get("sender"),
|
|
31
|
+
body=data["body"],
|
|
32
|
+
quantity=data["quantity"],
|
|
33
|
+
delivery_status=DeliveryStatus(data["delivery_status"]),
|
|
34
|
+
created_at=data["created_at"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Page:
|
|
40
|
+
count: int
|
|
41
|
+
next: Optional[str]
|
|
42
|
+
previous: Optional[str]
|
|
43
|
+
results: List[Message]
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def from_dict(data: dict) -> "Page":
|
|
47
|
+
return Page(
|
|
48
|
+
count=data["count"],
|
|
49
|
+
next=data.get("next"),
|
|
50
|
+
previous=data.get("previous"),
|
|
51
|
+
results=[Message.from_dict(m) for m in data["results"]],
|
|
52
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from .error_mapping import raise_for_status
|
|
4
|
+
from .http import Transport
|
|
5
|
+
from .models import Message, Page
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SmsResource:
|
|
9
|
+
def __init__(self, transport: Transport):
|
|
10
|
+
self._transport = transport
|
|
11
|
+
|
|
12
|
+
def send(self, from_: str, to: list, body: str) -> Message:
|
|
13
|
+
status, raw = self._transport.request(
|
|
14
|
+
"POST", "/sms/", json_body={"from": from_, "to": to, "body": body}
|
|
15
|
+
)
|
|
16
|
+
raise_for_status(status, raw)
|
|
17
|
+
return Message.from_dict(json.loads(raw))
|
|
18
|
+
|
|
19
|
+
def list(self, page: int = 1, page_size: int = 30) -> Page:
|
|
20
|
+
status, raw = self._transport.request(
|
|
21
|
+
"GET", "/sms/", query={"page": page, "page_size": page_size}
|
|
22
|
+
)
|
|
23
|
+
raise_for_status(status, raw)
|
|
24
|
+
return Page.from_dict(json.loads(raw))
|
|
25
|
+
|
|
26
|
+
def get(self, message_id: str) -> Message:
|
|
27
|
+
status, raw = self._transport.request("GET", f"/sms/{message_id}/")
|
|
28
|
+
raise_for_status(status, raw)
|
|
29
|
+
return Message.from_dict(json.loads(raw))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
from typing import Mapping
|
|
5
|
+
|
|
6
|
+
from .exceptions import InvalidSignatureError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Webhooks:
|
|
10
|
+
@staticmethod
|
|
11
|
+
def verify(raw_body: str, headers: Mapping[str, str], secret: str) -> dict:
|
|
12
|
+
headers_lower = {k.lower(): v for k, v in headers.items()}
|
|
13
|
+
signature = headers_lower.get("x-sailup-signature")
|
|
14
|
+
timestamp = headers_lower.get("x-sailup-timestamp")
|
|
15
|
+
|
|
16
|
+
if not signature or not timestamp:
|
|
17
|
+
raise InvalidSignatureError("Missing signature headers")
|
|
18
|
+
|
|
19
|
+
expected = hmac.new(
|
|
20
|
+
secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
|
|
21
|
+
).hexdigest()
|
|
22
|
+
|
|
23
|
+
if not hmac.compare_digest(expected, signature):
|
|
24
|
+
raise InvalidSignatureError("Signature mismatch")
|
|
25
|
+
|
|
26
|
+
return json.loads(raw_body)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/kalourmade_sailup/__init__.py
|
|
3
|
+
src/kalourmade_sailup/client.py
|
|
4
|
+
src/kalourmade_sailup/error_mapping.py
|
|
5
|
+
src/kalourmade_sailup/exceptions.py
|
|
6
|
+
src/kalourmade_sailup/http.py
|
|
7
|
+
src/kalourmade_sailup/models.py
|
|
8
|
+
src/kalourmade_sailup/sms.py
|
|
9
|
+
src/kalourmade_sailup/webhooks.py
|
|
10
|
+
src/kalourmade_sailup.egg-info/PKG-INFO
|
|
11
|
+
src/kalourmade_sailup.egg-info/SOURCES.txt
|
|
12
|
+
src/kalourmade_sailup.egg-info/dependency_links.txt
|
|
13
|
+
src/kalourmade_sailup.egg-info/requires.txt
|
|
14
|
+
src/kalourmade_sailup.egg-info/top_level.txt
|
|
15
|
+
tests/test_client.py
|
|
16
|
+
tests/test_error_mapping.py
|
|
17
|
+
tests/test_exceptions.py
|
|
18
|
+
tests/test_models.py
|
|
19
|
+
tests/test_sms.py
|
|
20
|
+
tests/test_webhooks.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kalourmade_sailup
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from kalourmade_sailup.error_mapping import raise_for_status
|
|
4
|
+
from kalourmade_sailup.exceptions import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
RateLimitError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_ok_status_does_not_raise():
|
|
13
|
+
raise_for_status(200, "{}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_401_raises_authentication_error():
|
|
17
|
+
with pytest.raises(AuthenticationError):
|
|
18
|
+
raise_for_status(401, "{}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_429_raises_rate_limit_error():
|
|
22
|
+
with pytest.raises(RateLimitError):
|
|
23
|
+
raise_for_status(429, "{}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_400_and_422_raise_validation_error():
|
|
27
|
+
with pytest.raises(ValidationError):
|
|
28
|
+
raise_for_status(422, "{}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_other_4xx_5xx_raises_api_error_with_status_and_body():
|
|
32
|
+
with pytest.raises(ApiError) as exc_info:
|
|
33
|
+
raise_for_status(503, '{"detail":"down"}')
|
|
34
|
+
|
|
35
|
+
assert exc_info.value.status_code == 503
|
|
36
|
+
assert exc_info.value.raw_body == '{"detail":"down"}'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from kalourmade_sailup.exceptions import (
|
|
2
|
+
ApiError,
|
|
3
|
+
AuthenticationError,
|
|
4
|
+
InvalidSignatureError,
|
|
5
|
+
NetworkError,
|
|
6
|
+
RateLimitError,
|
|
7
|
+
SailupError,
|
|
8
|
+
ValidationError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_all_exceptions_extend_sailup_error():
|
|
13
|
+
assert issubclass(AuthenticationError, SailupError)
|
|
14
|
+
assert issubclass(ValidationError, SailupError)
|
|
15
|
+
assert issubclass(RateLimitError, SailupError)
|
|
16
|
+
assert issubclass(NetworkError, SailupError)
|
|
17
|
+
assert issubclass(InvalidSignatureError, SailupError)
|
|
18
|
+
assert issubclass(ApiError, SailupError)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_api_error_carries_status_and_raw_body():
|
|
22
|
+
error = ApiError(503, '{"detail":"down"}', "Sailup API error (status 503)")
|
|
23
|
+
|
|
24
|
+
assert error.status_code == 503
|
|
25
|
+
assert error.raw_body == '{"detail":"down"}'
|
|
26
|
+
assert str(error) == "Sailup API error (status 503)"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from kalourmade_sailup.models import DeliveryStatus, Message, Page
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def message_dict(**overrides):
|
|
5
|
+
base = {
|
|
6
|
+
"id": "msg_123",
|
|
7
|
+
"to": ["233241234567"],
|
|
8
|
+
"sender": "MyBrand",
|
|
9
|
+
"body": "Hello",
|
|
10
|
+
"quantity": 1,
|
|
11
|
+
"delivery_status": "delivered",
|
|
12
|
+
"created_at": "2026-09-12T10:00:00Z",
|
|
13
|
+
}
|
|
14
|
+
base.update(overrides)
|
|
15
|
+
return base
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_message_from_dict_maps_all_fields():
|
|
19
|
+
message = Message.from_dict(message_dict())
|
|
20
|
+
|
|
21
|
+
assert message.id == "msg_123"
|
|
22
|
+
assert message.to == ["233241234567"]
|
|
23
|
+
assert message.delivery_status == DeliveryStatus.DELIVERED
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_message_from_dict_wraps_scalar_to_in_list():
|
|
27
|
+
message = Message.from_dict(message_dict(to="233241234567", sender=None))
|
|
28
|
+
|
|
29
|
+
assert message.to == ["233241234567"]
|
|
30
|
+
assert message.sender is None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_page_from_dict_maps_envelope_and_results():
|
|
34
|
+
page = Page.from_dict({
|
|
35
|
+
"count": 1,
|
|
36
|
+
"next": None,
|
|
37
|
+
"previous": None,
|
|
38
|
+
"results": [message_dict()],
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
assert page.count == 1
|
|
42
|
+
assert page.next is None
|
|
43
|
+
assert len(page.results) == 1
|
|
44
|
+
assert isinstance(page.results[0], Message)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from kalourmade_sailup.exceptions import AuthenticationError
|
|
6
|
+
from kalourmade_sailup.sms import SmsResource
|
|
7
|
+
from tests.fakes import FakeTransport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def message_json():
|
|
11
|
+
return json.dumps({
|
|
12
|
+
"id": "msg_123",
|
|
13
|
+
"to": ["233241234567"],
|
|
14
|
+
"sender": "MyBrand",
|
|
15
|
+
"body": "Hello",
|
|
16
|
+
"quantity": 1,
|
|
17
|
+
"delivery_status": "pending",
|
|
18
|
+
"created_at": "2026-09-12T10:00:00Z",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_send_posts_to_sms_and_returns_message():
|
|
23
|
+
transport = FakeTransport([(202, message_json())])
|
|
24
|
+
resource = SmsResource(transport)
|
|
25
|
+
|
|
26
|
+
message = resource.send("MyBrand", ["233241234567"], "Hello")
|
|
27
|
+
|
|
28
|
+
assert message.id == "msg_123"
|
|
29
|
+
assert transport.requests[0]["method"] == "POST"
|
|
30
|
+
assert transport.requests[0]["path"] == "/sms/"
|
|
31
|
+
assert transport.requests[0]["json_body"] == {
|
|
32
|
+
"from": "MyBrand",
|
|
33
|
+
"to": ["233241234567"],
|
|
34
|
+
"body": "Hello",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_list_gets_sms_with_pagination_query():
|
|
39
|
+
envelope = json.dumps({
|
|
40
|
+
"count": 1,
|
|
41
|
+
"next": None,
|
|
42
|
+
"previous": None,
|
|
43
|
+
"results": [json.loads(message_json())],
|
|
44
|
+
})
|
|
45
|
+
transport = FakeTransport([(200, envelope)])
|
|
46
|
+
resource = SmsResource(transport)
|
|
47
|
+
|
|
48
|
+
page = resource.list(page=2, page_size=50)
|
|
49
|
+
|
|
50
|
+
assert page.count == 1
|
|
51
|
+
assert len(page.results) == 1
|
|
52
|
+
assert transport.requests[0]["query"] == {"page": 2, "page_size": 50}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_get_fetches_single_message_by_id():
|
|
56
|
+
transport = FakeTransport([(200, message_json())])
|
|
57
|
+
resource = SmsResource(transport)
|
|
58
|
+
|
|
59
|
+
message = resource.get("msg_123")
|
|
60
|
+
|
|
61
|
+
assert message.id == "msg_123"
|
|
62
|
+
assert transport.requests[0]["path"] == "/sms/msg_123/"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_error_status_raises_mapped_exception():
|
|
66
|
+
transport = FakeTransport([(401, "{}")])
|
|
67
|
+
resource = SmsResource(transport)
|
|
68
|
+
|
|
69
|
+
with pytest.raises(AuthenticationError):
|
|
70
|
+
resource.get("msg_123")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from kalourmade_sailup.exceptions import InvalidSignatureError
|
|
8
|
+
from kalourmade_sailup.webhooks import Webhooks
|
|
9
|
+
|
|
10
|
+
SECRET = "whsec_test"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def sign(timestamp: str, raw_body: str) -> str:
|
|
14
|
+
return hmac.new(SECRET.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256).hexdigest()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_verify_returns_decoded_payload_for_valid_signature():
|
|
18
|
+
raw_body = json.dumps({"event": "message.delivered", "data": {"message_id": "msg_123"}})
|
|
19
|
+
timestamp = "1700000000"
|
|
20
|
+
signature = sign(timestamp, raw_body)
|
|
21
|
+
|
|
22
|
+
event = Webhooks.verify(
|
|
23
|
+
raw_body,
|
|
24
|
+
{"X-Sailup-Signature": signature, "X-Sailup-Timestamp": timestamp},
|
|
25
|
+
SECRET,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
assert event["event"] == "message.delivered"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_verify_raises_on_tampered_body():
|
|
32
|
+
raw_body = json.dumps({"event": "message.delivered"})
|
|
33
|
+
timestamp = "1700000000"
|
|
34
|
+
signature = sign(timestamp, raw_body)
|
|
35
|
+
tampered = json.dumps({"event": "message.failed"})
|
|
36
|
+
|
|
37
|
+
with pytest.raises(InvalidSignatureError):
|
|
38
|
+
Webhooks.verify(
|
|
39
|
+
tampered,
|
|
40
|
+
{"X-Sailup-Signature": signature, "X-Sailup-Timestamp": timestamp},
|
|
41
|
+
SECRET,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_verify_raises_on_missing_headers():
|
|
46
|
+
with pytest.raises(InvalidSignatureError):
|
|
47
|
+
Webhooks.verify("{}", {}, SECRET)
|