Terminal / tests /test_webhook_security.py
Baida07's picture
sync: 188 file da Baida98/AI@c343c9b6 (2026-08-25 20:01 UTC) [deploy-all] (#94)
871f883
Raw
History Blame
5.46 kB
"""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()