Spaces:
Running
Running
| """Contratti di sicurezza del webhook HMAC e della sua idempotenza.""" | |
| from __future__ import annotations | |
| import hashlib | |
| import hmac | |
| import os | |
| import sys | |
| import unittest | |
| from unittest.mock import patch | |
| from fastapi import HTTPException | |
| _BACKEND = os.path.join(os.path.dirname(__file__), "..") | |
| if _BACKEND not in sys.path: | |
| sys.path.insert(0, _BACKEND) | |
| from api.webhook_security import ( # noqa: E402 | |
| WebhookDeliveryStore, | |
| _signing_bytes, | |
| verify_webhook_request, | |
| ) | |
| _SECRET = "s" * 32 | |
| _EVENT_ID = "evt-security-0001" | |
| _NOW = 1_700_000_000 | |
| def _headers(raw: bytes, *, event_id: str = _EVENT_ID, timestamp: int = _NOW) -> dict[str, str]: | |
| timestamp_raw = str(timestamp) | |
| digest = hmac.new( | |
| _SECRET.encode("utf-8"), | |
| _signing_bytes(timestamp_raw, event_id, raw), | |
| hashlib.sha256, | |
| ).hexdigest() | |
| return { | |
| "x-webhook-id": event_id, | |
| "x-webhook-timestamp": timestamp_raw, | |
| "x-webhook-signature": f"sha256={digest}", | |
| } | |
| class WebhookSecurityTests(unittest.IsolatedAsyncioTestCase): | |
| async def asyncSetUp(self) -> None: | |
| await WebhookDeliveryStore.reset_memory_for_test() | |
| def test_signature_binds_the_exact_raw_bytes_and_fresh_timestamp(self) -> None: | |
| raw = b'{"goal":"safe", "context":[]}' | |
| with patch.dict(os.environ, {"WEBHOOK_HMAC_SECRET": _SECRET}, clear=False): | |
| verified = verify_webhook_request(raw, _headers(raw), now=_NOW) | |
| self.assertEqual(verified.event_id, _EVENT_ID) | |
| self.assertEqual(verified.payload_sha256, hashlib.sha256(raw).hexdigest()) | |
| with self.assertRaises(HTTPException) as altered: | |
| verify_webhook_request(b'{"goal":"safe","context":[]}', _headers(raw), now=_NOW) | |
| with self.assertRaises(HTTPException) as future: | |
| verify_webhook_request(raw, _headers(raw, timestamp=_NOW + 301), now=_NOW) | |
| self.assertEqual(altered.exception.status_code, 401) | |
| self.assertEqual(future.exception.status_code, 401) | |
| def test_required_headers_and_secret_fail_closed(self) -> None: | |
| raw = b'{"goal":"safe"}' | |
| with patch.dict(os.environ, {"WEBHOOK_HMAC_SECRET": "too-short"}, clear=False): | |
| with self.assertRaises(HTTPException) as missing_secret: | |
| verify_webhook_request(raw, _headers(raw), now=_NOW) | |
| with patch.dict(os.environ, {"WEBHOOK_HMAC_SECRET": _SECRET}, clear=False): | |
| malformed = _headers(raw) | |
| malformed["x-webhook-id"] = "short" | |
| with self.assertRaises(HTTPException) as invalid_id: | |
| verify_webhook_request(raw, malformed, now=_NOW) | |
| missing_signature = _headers(raw) | |
| missing_signature.pop("x-webhook-signature") | |
| with self.assertRaises(HTTPException) as unsigned: | |
| verify_webhook_request(raw, missing_signature, now=_NOW) | |
| self.assertEqual(missing_secret.exception.status_code, 503) | |
| self.assertEqual(invalid_id.exception.status_code, 400) | |
| self.assertEqual(unsigned.exception.status_code, 401) | |
| async def test_memory_state_machine_caches_replay_conflicts_and_releases_pre_dispatch(self) -> None: | |
| raw = b'{"goal":"safe"}' | |
| other_raw = b'{"goal":"other"}' | |
| with patch.dict( | |
| os.environ, | |
| {"WEBHOOK_HMAC_SECRET": _SECRET, "WEBHOOK_IDEMPOTENCY_REQUIRE_DURABLE": "false"}, | |
| clear=False, | |
| ): | |
| verified = verify_webhook_request(raw, _headers(raw), now=_NOW) | |
| conflicting = verify_webhook_request(other_raw, _headers(other_raw), now=_NOW) | |
| store = WebhookDeliveryStore(None) | |
| self.assertEqual((await store.claim(verified)).decision, "claimed") | |
| self.assertEqual((await store.claim(verified)).decision, "in_progress") | |
| self.assertEqual((await store.claim(conflicting)).decision, "conflict") | |
| await store.complete(verified, {"ok": True, "output": "cached"}) | |
| replay = await store.claim(verified) | |
| self.assertEqual(replay.decision, "replay") | |
| self.assertEqual(replay.response, {"ok": True, "output": "cached"}) | |
| release_id = "evt-release-00001" | |
| releasing = verify_webhook_request(raw, _headers(raw, event_id=release_id), now=_NOW) | |
| self.assertEqual((await store.claim(releasing)).decision, "claimed") | |
| await store.release(releasing) | |
| self.assertEqual((await store.claim(releasing)).decision, "claimed") | |
| async def test_durable_store_is_required_by_default_and_rpc_errors_are_unavailable(self) -> None: | |
| raw = b'{"goal":"safe"}' | |
| with patch.dict(os.environ, {"WEBHOOK_HMAC_SECRET": _SECRET, "WEBHOOK_IDEMPOTENCY_REQUIRE_DURABLE": "true"}, clear=False): | |
| verified = verify_webhook_request(raw, _headers(raw), now=_NOW) | |
| with self.assertRaises(HTTPException) as absent_store: | |
| await WebhookDeliveryStore(None).claim(verified) | |
| with self.assertRaises(HTTPException) as broken_store: | |
| await WebhookDeliveryStore(_BrokenSupabase()).claim(verified) | |
| self.assertEqual(absent_store.exception.status_code, 503) | |
| self.assertEqual(broken_store.exception.status_code, 503) | |
| class _BrokenSupabase: | |
| def rpc(self, *_args: object, **_kwargs: object) -> object: | |
| raise RuntimeError("database unavailable") | |
| if __name__ == "__main__": | |
| unittest.main() | |