algovoi-webhook-verifier 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.
@@ -0,0 +1,30 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .eggs/
13
+ .pytest_cache/
14
+ *.pyc
15
+
16
+ # TypeScript / Node
17
+ node_modules/
18
+ typescript/dist/
19
+ typescript/node_modules/
20
+ *.tsbuildinfo
21
+
22
+ # Editors
23
+ .vscode/
24
+ .idea/
25
+ *.swp
26
+ *.swo
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: algovoi-webhook-verifier
3
+ Version: 0.1.0
4
+ Summary: Cryptographic verifier for AlgoVoi webhook signatures (v1 HMAC-SHA256 + v2 HKDF-SHA256/HMAC-SHA384)
5
+ Project-URL: Homepage, https://docs.algovoi.co.uk/webhook-verifier
6
+ Project-URL: Repository, https://github.com/chopmob-cloud/algovoi-webhook-verifier
7
+ Project-URL: Bug Tracker, https://github.com/chopmob-cloud/algovoi-webhook-verifier/issues
8
+ Author-email: AlgoVoi <dev@algovoi.co.uk>
9
+ License: Apache-2.0
10
+ Keywords: algovoi,hmac,signature,verification,webhook
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security :: Cryptography
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: cryptography>=41.0.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ See the [repository README](https://github.com/chopmob-cloud/algovoi-webhook-verifier) for full documentation.
@@ -0,0 +1 @@
1
+ See the [repository README](https://github.com/chopmob-cloud/algovoi-webhook-verifier) for full documentation.
@@ -0,0 +1,16 @@
1
+ """AlgoVoi webhook verifier — public API."""
2
+
3
+ from .errors import ERROR_CODES, ErrorCode, WebhookVerificationError
4
+ from .verify import DEFAULT_TOLERANCE, KNOWN_EVENT_TYPES, SIGNATURE_HEADER, verify_webhook
5
+
6
+ __all__ = [
7
+ "verify_webhook",
8
+ "WebhookVerificationError",
9
+ "ErrorCode",
10
+ "ERROR_CODES",
11
+ "SIGNATURE_HEADER",
12
+ "DEFAULT_TOLERANCE",
13
+ "KNOWN_EVENT_TYPES",
14
+ ]
15
+
16
+ __version__ = "0.1.0"
@@ -0,0 +1,40 @@
1
+ """Typed error codes for AlgoVoi webhook verification failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ ERROR_CODES = frozenset({
8
+ "MISSING_SIGNATURE",
9
+ "MALFORMED_SIGNATURE",
10
+ "STALE_SIGNATURE",
11
+ "INVALID_SIGNATURE",
12
+ "INVALID_PAYLOAD",
13
+ "UNKNOWN_EVENT_TYPE",
14
+ })
15
+
16
+ ErrorCode = Literal[
17
+ "MISSING_SIGNATURE",
18
+ "MALFORMED_SIGNATURE",
19
+ "STALE_SIGNATURE",
20
+ "INVALID_SIGNATURE",
21
+ "INVALID_PAYLOAD",
22
+ "UNKNOWN_EVENT_TYPE",
23
+ ]
24
+
25
+
26
+ class WebhookVerificationError(Exception):
27
+ """Raised when webhook signature verification fails.
28
+
29
+ Attributes:
30
+ code: One of the six ``ERROR_CODES`` values.
31
+ message: Human-readable description.
32
+ """
33
+
34
+ def __init__(self, code: ErrorCode, message: str) -> None:
35
+ super().__init__(message)
36
+ self.code: ErrorCode = code
37
+ self.message: str = message
38
+
39
+ def __repr__(self) -> str:
40
+ return f"WebhookVerificationError(code={self.code!r}, message={self.message!r})"
@@ -0,0 +1,171 @@
1
+ """AlgoVoi webhook signature verifier — v1 (HMAC-SHA256) and v2 (HKDF-SHA256 + HMAC-SHA384)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import re
9
+ import time
10
+ from typing import Any
11
+
12
+ from cryptography.hazmat.primitives import hashes
13
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
14
+
15
+ from .errors import WebhookVerificationError
16
+
17
+ # Header name used by the AlgoVoi gateway
18
+ SIGNATURE_HEADER = "X-AlgoVoi-Signature"
19
+
20
+ # Default replay-prevention window (seconds)
21
+ DEFAULT_TOLERANCE = 300
22
+
23
+ # Supported event types
24
+ KNOWN_EVENT_TYPES = frozenset({"payment.confirmed"})
25
+
26
+ _SIG_RE = re.compile(
27
+ r"^t=(?P<ts>\d+),v1=(?P<v1>[0-9a-f]{64})(?:,v2=(?P<v2>[0-9a-f]{96}))?$"
28
+ )
29
+
30
+
31
+ def _derive_v2_key(secret_bytes: bytes) -> bytes:
32
+ """HKDF-SHA256 key derivation matching the gateway _sign() implementation."""
33
+ hkdf = HKDF(
34
+ algorithm=hashes.SHA256(),
35
+ length=48,
36
+ salt=b"algovoi-webhook-v2-pqc",
37
+ info=b"hmac-sha384-outbound",
38
+ )
39
+ return hkdf.derive(secret_bytes)
40
+
41
+
42
+ def _compute_sigs(secret: str, ts: int, body: bytes) -> tuple[str, str]:
43
+ """Return (v1_hex, v2_hex) for the given secret, timestamp, and body."""
44
+ secret_bytes = secret.encode()
45
+ signed_payload = f"{ts}.".encode() + body
46
+ v1 = hmac.new(secret_bytes, signed_payload, hashlib.sha256).hexdigest()
47
+ v2_key = _derive_v2_key(secret_bytes)
48
+ v2 = hmac.new(v2_key, signed_payload, hashlib.sha384).hexdigest()
49
+ return v1, v2
50
+
51
+
52
+ def verify_webhook(
53
+ *,
54
+ payload: bytes,
55
+ secret: str,
56
+ signature_header: str,
57
+ tolerance: int = DEFAULT_TOLERANCE,
58
+ require_v2: bool = False,
59
+ ) -> dict[str, Any]:
60
+ """Verify an AlgoVoi webhook signature and return the parsed event payload.
61
+
62
+ Parameters
63
+ ----------
64
+ payload:
65
+ The raw request body bytes exactly as received (do not decode first).
66
+ secret:
67
+ The webhook signing secret for the tenant (``algvw_*`` prefixed string).
68
+ signature_header:
69
+ The value of the ``X-AlgoVoi-Signature`` header.
70
+ tolerance:
71
+ Maximum age (seconds) of a valid signature. Default 300. Pass ``0``
72
+ to disable staleness checking (test environments only).
73
+ require_v2:
74
+ If ``True`` the v2 component *must* be present and valid. Default
75
+ ``False`` (v2 is validated when present; absent v2 is accepted).
76
+
77
+ Returns
78
+ -------
79
+ dict
80
+ The parsed JSON event object.
81
+
82
+ Raises
83
+ ------
84
+ WebhookVerificationError
85
+ On any verification failure. Inspect ``.code`` for the specific
86
+ failure mode.
87
+ """
88
+ # ------------------------------------------------------------------ #
89
+ # 1. Presence check #
90
+ # ------------------------------------------------------------------ #
91
+ trimmed_header = signature_header.strip() if signature_header else ""
92
+ if not trimmed_header:
93
+ raise WebhookVerificationError(
94
+ "MISSING_SIGNATURE",
95
+ "X-AlgoVoi-Signature header is absent",
96
+ )
97
+
98
+ # ------------------------------------------------------------------ #
99
+ # 2. Parse header #
100
+ # ------------------------------------------------------------------ #
101
+ m = _SIG_RE.match(trimmed_header)
102
+ if not m:
103
+ raise WebhookVerificationError(
104
+ "MALFORMED_SIGNATURE",
105
+ f"Signature header does not match expected format t=<unix>,v1=<sha256hex>[,v2=<sha384hex>]: {signature_header!r}",
106
+ )
107
+
108
+ ts = int(m.group("ts"))
109
+ received_v1: str = m.group("v1")
110
+ received_v2: str | None = m.group("v2")
111
+
112
+ # ------------------------------------------------------------------ #
113
+ # 3. Staleness check #
114
+ # ------------------------------------------------------------------ #
115
+ if tolerance > 0:
116
+ age = abs(int(time.time()) - ts)
117
+ if age > tolerance:
118
+ raise WebhookVerificationError(
119
+ "STALE_SIGNATURE",
120
+ f"Signature timestamp {ts} is {age}s old (tolerance {tolerance}s)",
121
+ )
122
+
123
+ # ------------------------------------------------------------------ #
124
+ # 4. HMAC computation #
125
+ # ------------------------------------------------------------------ #
126
+ expected_v1, expected_v2 = _compute_sigs(secret, ts, payload)
127
+
128
+ # ------------------------------------------------------------------ #
129
+ # 5. Signature comparison #
130
+ # ------------------------------------------------------------------ #
131
+ v1_ok = hmac.compare_digest(received_v1, expected_v1)
132
+
133
+ if received_v2 is not None:
134
+ v2_ok = hmac.compare_digest(received_v2, expected_v2)
135
+ else:
136
+ v2_ok = not require_v2 # absent v2 is fine unless caller requires it
137
+
138
+ if not v1_ok or not v2_ok:
139
+ raise WebhookVerificationError(
140
+ "INVALID_SIGNATURE",
141
+ "One or more signature components did not match",
142
+ )
143
+
144
+ # ------------------------------------------------------------------ #
145
+ # 6. Payload parse #
146
+ # ------------------------------------------------------------------ #
147
+ try:
148
+ event: dict[str, Any] = json.loads(payload)
149
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
150
+ raise WebhookVerificationError(
151
+ "INVALID_PAYLOAD",
152
+ f"Payload is not valid JSON: {exc}",
153
+ ) from exc
154
+
155
+ if not isinstance(event, dict):
156
+ raise WebhookVerificationError(
157
+ "INVALID_PAYLOAD",
158
+ "Payload root must be a JSON object",
159
+ )
160
+
161
+ # ------------------------------------------------------------------ #
162
+ # 7. Event-type check #
163
+ # ------------------------------------------------------------------ #
164
+ event_type = event.get("type")
165
+ if event_type not in KNOWN_EVENT_TYPES:
166
+ raise WebhookVerificationError(
167
+ "UNKNOWN_EVENT_TYPE",
168
+ f"Unrecognised event type: {event_type!r}",
169
+ )
170
+
171
+ return event
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "algovoi-webhook-verifier"
7
+ version = "0.1.0"
8
+ description = "Cryptographic verifier for AlgoVoi webhook signatures (v1 HMAC-SHA256 + v2 HKDF-SHA256/HMAC-SHA384)"
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "AlgoVoi", email = "dev@algovoi.co.uk" }]
13
+ keywords = ["webhook", "hmac", "signature", "verification", "algovoi"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security :: Cryptography",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = [
27
+ "cryptography>=41.0.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://docs.algovoi.co.uk/webhook-verifier"
32
+ Repository = "https://github.com/chopmob-cloud/algovoi-webhook-verifier"
33
+ "Bug Tracker" = "https://github.com/chopmob-cloud/algovoi-webhook-verifier/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["algovoi_webhook_verifier"]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,72 @@
1
+ """Cross-validation vector tests for algovoi_webhook_verifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ import pytest
10
+
11
+ from algovoi_webhook_verifier import WebhookVerificationError, verify_webhook
12
+
13
+ VECTORS_ROOT = Path(__file__).parent.parent.parent / "vectors"
14
+
15
+
16
+ def _load_vectors(subdir: str):
17
+ d = VECTORS_ROOT / subdir
18
+ if not d.exists():
19
+ pytest.skip(f"Vector directory not found: {d}")
20
+ return sorted(d.glob("*.json"))
21
+
22
+
23
+ # --------------------------------------------------------------------------- #
24
+ # Valid vectors — must all succeed #
25
+ # --------------------------------------------------------------------------- #
26
+
27
+ @pytest.mark.parametrize("fixture_path", _load_vectors("valid"))
28
+ def test_valid_vector(fixture_path):
29
+ fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
30
+ body = fixture["body"].encode("utf-8")
31
+ event = verify_webhook(
32
+ payload=body,
33
+ secret=fixture["secret"],
34
+ signature_header=fixture["signature_header"],
35
+ tolerance=0, # vectors use fixed timestamps — disable staleness check
36
+ )
37
+ assert event["type"] == "payment.confirmed"
38
+
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Invalid vectors — must raise with matching error_code #
42
+ # --------------------------------------------------------------------------- #
43
+
44
+ @pytest.mark.parametrize("fixture_path", _load_vectors("invalid"))
45
+ def test_invalid_vector(fixture_path):
46
+ fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
47
+ body = fixture["body"].encode("utf-8")
48
+ tolerance = fixture.get("tolerance", 0)
49
+ fake_now = fixture.get("fake_now")
50
+
51
+ expected_code = fixture["error_code"]
52
+
53
+ def _run():
54
+ verify_webhook(
55
+ payload=body,
56
+ secret=fixture["secret"],
57
+ signature_header=fixture["signature_header"],
58
+ tolerance=tolerance,
59
+ )
60
+
61
+ with pytest.raises(WebhookVerificationError) as exc_info:
62
+ if fake_now is not None:
63
+ with patch("algovoi_webhook_verifier.verify.time") as mock_time:
64
+ mock_time.time.return_value = fake_now
65
+ _run()
66
+ else:
67
+ _run()
68
+
69
+ assert exc_info.value.code == expected_code, (
70
+ f"Vector {fixture_path.name}: expected error code {expected_code!r}, "
71
+ f"got {exc_info.value.code!r}"
72
+ )
@@ -0,0 +1,316 @@
1
+ """Unit tests for algovoi_webhook_verifier.verify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import time
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+ from cryptography.hazmat.primitives import hashes
13
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
14
+
15
+ from algovoi_webhook_verifier import WebhookVerificationError, verify_webhook
16
+
17
+ # --------------------------------------------------------------------------- #
18
+ # Helpers #
19
+ # --------------------------------------------------------------------------- #
20
+
21
+ SECRET = "algvw_test_unit_secret"
22
+ NOW = 1748650000 # fixed timestamp for all tests
23
+
24
+
25
+ def _derive_v2_key(secret_bytes: bytes) -> bytes:
26
+ return HKDF(
27
+ algorithm=hashes.SHA256(),
28
+ length=48,
29
+ salt=b"algovoi-webhook-v2-pqc",
30
+ info=b"hmac-sha384-outbound",
31
+ ).derive(secret_bytes)
32
+
33
+
34
+ def _sign(secret: str, ts: int, body: bytes) -> str:
35
+ sb = secret.encode()
36
+ sp = f"{ts}.".encode() + body
37
+ v1 = hmac.new(sb, sp, hashlib.sha256).hexdigest()
38
+ v2_key = _derive_v2_key(sb)
39
+ v2 = hmac.new(v2_key, sp, hashlib.sha384).hexdigest()
40
+ return f"t={ts},v1={v1},v2={v2}"
41
+
42
+
43
+ def _payment_body(**overrides) -> bytes:
44
+ data = {
45
+ "id": "evt_unit_001",
46
+ "type": "payment.confirmed",
47
+ "created": NOW,
48
+ "api_version": "2024-01-01",
49
+ "data": {},
50
+ }
51
+ data.update(overrides)
52
+ return json.dumps(data, separators=(",", ":")).encode()
53
+
54
+
55
+ def _call(body=None, secret=SECRET, ts=NOW, **kw):
56
+ """Helper: sign body with secret+ts and call verify_webhook with tolerance=0."""
57
+ if body is None:
58
+ body = _payment_body()
59
+ header = _sign(secret, ts, body)
60
+ return verify_webhook(
61
+ payload=body,
62
+ secret=secret,
63
+ signature_header=header,
64
+ tolerance=0,
65
+ **kw,
66
+ )
67
+
68
+
69
+ # --------------------------------------------------------------------------- #
70
+ # Happy-path #
71
+ # --------------------------------------------------------------------------- #
72
+
73
+ class TestHappyPath:
74
+ def test_returns_parsed_event(self):
75
+ event = _call()
76
+ assert event["type"] == "payment.confirmed"
77
+ assert event["id"] == "evt_unit_001"
78
+
79
+ def test_v1_only_accepted_by_default(self):
80
+ body = _payment_body()
81
+ sb = SECRET.encode()
82
+ sp = f"{NOW}.".encode() + body
83
+ v1 = hmac.new(sb, sp, hashlib.sha256).hexdigest()
84
+ header = f"t={NOW},v1={v1}"
85
+ event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
86
+ assert event["type"] == "payment.confirmed"
87
+
88
+ def test_v2_validated_when_present(self):
89
+ # Passing require_v2=True with a full sig should succeed
90
+ event = _call(require_v2=True)
91
+ assert event["type"] == "payment.confirmed"
92
+
93
+ def test_whitespace_trimmed_from_header(self):
94
+ body = _payment_body()
95
+ header = " " + _sign(SECRET, NOW, body) + " "
96
+ event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
97
+ assert event["type"] == "payment.confirmed"
98
+
99
+ def test_custom_tolerance_accepted(self):
100
+ body = _payment_body()
101
+ # ts = NOW - 100, fake time = NOW → age = 100 < tolerance 200
102
+ old_ts = NOW - 100
103
+ header = _sign(SECRET, old_ts, body)
104
+ with patch("algovoi_webhook_verifier.verify.time") as mock_time:
105
+ mock_time.time.return_value = NOW
106
+ event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=200)
107
+ assert event["type"] == "payment.confirmed"
108
+
109
+ def test_unicode_body(self):
110
+ body = json.dumps({
111
+ "id": "evt_u_001",
112
+ "type": "payment.confirmed",
113
+ "created": NOW,
114
+ "api_version": "2024-01-01",
115
+ "data": {"label": "café"},
116
+ }, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
117
+ header = _sign(SECRET, NOW, body)
118
+ event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
119
+ assert event["data"]["label"] == "café"
120
+
121
+
122
+ # --------------------------------------------------------------------------- #
123
+ # MISSING_SIGNATURE #
124
+ # --------------------------------------------------------------------------- #
125
+
126
+ class TestMissingSignature:
127
+ @pytest.mark.parametrize("header", ["", None])
128
+ def test_empty_or_none_header(self, header):
129
+ body = _payment_body()
130
+ with pytest.raises(WebhookVerificationError) as exc_info:
131
+ verify_webhook(payload=body, secret=SECRET, signature_header=header or "", tolerance=0)
132
+ assert exc_info.value.code == "MISSING_SIGNATURE"
133
+
134
+ def test_error_message_contains_header_name(self):
135
+ with pytest.raises(WebhookVerificationError) as exc_info:
136
+ verify_webhook(payload=_payment_body(), secret=SECRET, signature_header="", tolerance=0)
137
+ assert "X-AlgoVoi-Signature" in exc_info.value.message
138
+
139
+
140
+ # --------------------------------------------------------------------------- #
141
+ # MALFORMED_SIGNATURE #
142
+ # --------------------------------------------------------------------------- #
143
+
144
+ class TestMalformedSignature:
145
+ @pytest.mark.parametrize("bad_header", [
146
+ "v1=abc123",
147
+ "t=abc,v1=abc", # non-numeric ts
148
+ "t=123,v2=abc", # missing v1
149
+ "t=123,v1=" + "a" * 63, # v1 too short
150
+ "t=123,v1=" + "a" * 65, # v1 too long
151
+ "t=123,v1=" + "g" * 64, # non-hex v1
152
+ "randomgarbage",
153
+ ])
154
+ def test_rejects_bad_format(self, bad_header):
155
+ with pytest.raises(WebhookVerificationError) as exc_info:
156
+ verify_webhook(payload=_payment_body(), secret=SECRET, signature_header=bad_header, tolerance=0)
157
+ assert exc_info.value.code == "MALFORMED_SIGNATURE"
158
+
159
+
160
+ # --------------------------------------------------------------------------- #
161
+ # STALE_SIGNATURE #
162
+ # --------------------------------------------------------------------------- #
163
+
164
+ class TestStaleSignature:
165
+ def test_expired_timestamp(self):
166
+ body = _payment_body()
167
+ old_ts = NOW - 600
168
+ header = _sign(SECRET, old_ts, body)
169
+ with patch("algovoi_webhook_verifier.verify.time") as mock_time:
170
+ mock_time.time.return_value = NOW
171
+ with pytest.raises(WebhookVerificationError) as exc_info:
172
+ verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=300)
173
+ assert exc_info.value.code == "STALE_SIGNATURE"
174
+
175
+ def test_future_timestamp_beyond_tolerance(self):
176
+ body = _payment_body()
177
+ future_ts = NOW + 600
178
+ header = _sign(SECRET, future_ts, body)
179
+ with patch("algovoi_webhook_verifier.verify.time") as mock_time:
180
+ mock_time.time.return_value = NOW
181
+ with pytest.raises(WebhookVerificationError) as exc_info:
182
+ verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=300)
183
+ assert exc_info.value.code == "STALE_SIGNATURE"
184
+
185
+ def test_tolerance_zero_bypasses_check(self):
186
+ body = _payment_body()
187
+ old_ts = NOW - 99999
188
+ header = _sign(SECRET, old_ts, body)
189
+ # Should NOT raise even though ts is ancient
190
+ event = verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
191
+ assert event["type"] == "payment.confirmed"
192
+
193
+
194
+ # --------------------------------------------------------------------------- #
195
+ # INVALID_SIGNATURE #
196
+ # --------------------------------------------------------------------------- #
197
+
198
+ class TestInvalidSignature:
199
+ def test_wrong_secret(self):
200
+ body = _payment_body()
201
+ header = _sign("algvw_wrong_secret", NOW, body)
202
+ with pytest.raises(WebhookVerificationError) as exc_info:
203
+ verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
204
+ assert exc_info.value.code == "INVALID_SIGNATURE"
205
+
206
+ def test_tampered_body(self):
207
+ body = _payment_body()
208
+ header = _sign(SECRET, NOW, body)
209
+ tampered = body.replace(b"evt_unit_001", b"evt_tampered")
210
+ with pytest.raises(WebhookVerificationError) as exc_info:
211
+ verify_webhook(payload=tampered, secret=SECRET, signature_header=header, tolerance=0)
212
+ assert exc_info.value.code == "INVALID_SIGNATURE"
213
+
214
+ def test_corrupted_v1_hex(self):
215
+ body = _payment_body()
216
+ header = _sign(SECRET, NOW, body)
217
+ # Flip last character of v1
218
+ corrupted = header[:-1] + ("0" if header[-1] != "0" else "1")
219
+ with pytest.raises(WebhookVerificationError) as exc_info:
220
+ verify_webhook(payload=body, secret=SECRET, signature_header=corrupted, tolerance=0)
221
+ assert exc_info.value.code == "INVALID_SIGNATURE"
222
+
223
+ def test_require_v2_fails_when_v2_absent(self):
224
+ body = _payment_body()
225
+ sb = SECRET.encode()
226
+ sp = f"{NOW}.".encode() + body
227
+ v1 = hmac.new(sb, sp, hashlib.sha256).hexdigest()
228
+ header = f"t={NOW},v1={v1}" # no v2
229
+ with pytest.raises(WebhookVerificationError) as exc_info:
230
+ verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0, require_v2=True)
231
+ assert exc_info.value.code == "INVALID_SIGNATURE"
232
+
233
+ def test_corrupted_v2_hex(self):
234
+ body = _payment_body()
235
+ header = _sign(SECRET, NOW, body)
236
+ # Corrupt the v2 component
237
+ parts = header.split(",v2=")
238
+ bad_v2 = parts[1][:-1] + ("0" if parts[1][-1] != "0" else "1")
239
+ corrupted = parts[0] + ",v2=" + bad_v2
240
+ with pytest.raises(WebhookVerificationError) as exc_info:
241
+ verify_webhook(payload=body, secret=SECRET, signature_header=corrupted, tolerance=0)
242
+ assert exc_info.value.code == "INVALID_SIGNATURE"
243
+
244
+
245
+ # --------------------------------------------------------------------------- #
246
+ # INVALID_PAYLOAD #
247
+ # --------------------------------------------------------------------------- #
248
+
249
+ class TestInvalidPayload:
250
+ def test_not_json(self):
251
+ body = b"not-json"
252
+ header = _sign(SECRET, NOW, body)
253
+ with pytest.raises(WebhookVerificationError) as exc_info:
254
+ verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
255
+ assert exc_info.value.code == "INVALID_PAYLOAD"
256
+
257
+ def test_json_array_root(self):
258
+ body = b'["a","b"]'
259
+ header = _sign(SECRET, NOW, body)
260
+ with pytest.raises(WebhookVerificationError) as exc_info:
261
+ verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
262
+ assert exc_info.value.code == "INVALID_PAYLOAD"
263
+
264
+ def test_truncated_json(self):
265
+ body = b'{"type": "payment.confirmed"' # missing closing brace
266
+ header = _sign(SECRET, NOW, body)
267
+ with pytest.raises(WebhookVerificationError) as exc_info:
268
+ verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
269
+ assert exc_info.value.code == "INVALID_PAYLOAD"
270
+
271
+
272
+ # --------------------------------------------------------------------------- #
273
+ # UNKNOWN_EVENT_TYPE #
274
+ # --------------------------------------------------------------------------- #
275
+
276
+ class TestUnknownEventType:
277
+ @pytest.mark.parametrize("event_type", [
278
+ "refund.issued",
279
+ "payment.failed",
280
+ "mandate.cancelled",
281
+ None,
282
+ "",
283
+ ])
284
+ def test_unknown_types(self, event_type):
285
+ data = {
286
+ "id": "evt_unk",
287
+ "type": event_type,
288
+ "created": NOW,
289
+ "api_version": "2024-01-01",
290
+ "data": {},
291
+ }
292
+ body = json.dumps(data, separators=(",", ":")).encode()
293
+ header = _sign(SECRET, NOW, body)
294
+ with pytest.raises(WebhookVerificationError) as exc_info:
295
+ verify_webhook(payload=body, secret=SECRET, signature_header=header, tolerance=0)
296
+ assert exc_info.value.code == "UNKNOWN_EVENT_TYPE"
297
+
298
+
299
+ # --------------------------------------------------------------------------- #
300
+ # Error object shape #
301
+ # --------------------------------------------------------------------------- #
302
+
303
+ class TestErrorObject:
304
+ def test_error_has_code_and_message(self):
305
+ with pytest.raises(WebhookVerificationError) as exc_info:
306
+ verify_webhook(payload=_payment_body(), secret=SECRET, signature_header="", tolerance=0)
307
+ err = exc_info.value
308
+ assert isinstance(err.code, str)
309
+ assert isinstance(err.message, str)
310
+ assert str(err) == err.message
311
+
312
+ def test_repr_contains_code_and_message(self):
313
+ err = WebhookVerificationError("INVALID_SIGNATURE", "test msg")
314
+ r = repr(err)
315
+ assert "INVALID_SIGNATURE" in r
316
+ assert "test msg" in r