sync: 188 file da Baida98/AI@c343c9b6 (2026-08-25 20:01 UTC) [deploy-all]

#94
by Baida07 - opened
api/webhook.py CHANGED
@@ -2,10 +2,11 @@
2
  import os, asyncio
3
  from typing import Optional
4
  from fastapi import APIRouter, Depends, HTTPException, Request
5
- from pydantic import BaseModel, field_validator
6
  from .state import _get_mem_manager, _get_executor, _get_planner
7
  from .auth_guard import require_role, AuthRole # GAP-WEBHOOK-ADMIN-FIX
8
  from .task_tool_policy import build_task_tool_policy
 
9
 
10
  import logging
11
  _logger = logging.getLogger("api.webhook")
@@ -201,10 +202,12 @@ class PublicChatPayload(BaseModel):
201
 
202
 
203
  @router.post('/api/webhook/{webhook_token}')
204
- async def inbound_webhook(webhook_token: str, body: WebhookPayload):
205
- """
206
- S289 — Webhook inbound per triggering proattivo dell'agente.
207
- Auth: WEBHOOK_TOKEN env var deve matchare il path token.
 
 
208
  """
209
  _expected = os.getenv('WEBHOOK_TOKEN', '').strip()
210
  if not _expected:
@@ -212,14 +215,36 @@ async def inbound_webhook(webhook_token: str, body: WebhookPayload):
212
  if webhook_token != _expected:
213
  raise HTTPException(status_code=401, detail='Unauthorized: token non valido')
214
 
215
- _task_policy = build_task_tool_policy(body.goal)
216
- if _task_policy.literal_response:
217
- return {
218
- 'ok': True, 'output': _task_policy.literal_response, 'engine': 'policy',
219
- 'goal': body.goal, 'steps': 0,
220
- }
221
-
222
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  from agents.unified_loop import UnifiedAgentLoop
224
  from models.ai_client import AIClient
225
  client = AIClient()
@@ -238,6 +263,9 @@ async def inbound_webhook(webhook_token: str, body: WebhookPayload):
238
  context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
239
  _wh_task_id = f"wh_{webhook_token[:6]}_{int(__import__('time').time()*1000)%999983:x}"
240
  await _tg_start(_wh_task_id, body.goal)
 
 
 
241
  try:
242
  result = await asyncio.wait_for(
243
  loop.run(goal=body.goal, context=context_str,
@@ -253,17 +281,23 @@ async def inbound_webhook(webhook_token: str, body: WebhookPayload):
253
  raise HTTPException(status_code=500, detail=f'Errore agente: {exc}')
254
  _out = result.get('output', '')
255
  asyncio.ensure_future(_tg_done(_wh_task_id, body.goal, _out[:500]))
256
- return {
257
  'ok': result.get('success', False),
258
  'output': _out,
259
  'engine': result.get('engine', 'unknown'),
260
  'goal': body.goal,
261
  'steps': len(result.get('steps', [])),
262
  }
263
- except (HTTPException, asyncio.TimeoutError):
 
 
 
 
264
  raise
265
  except Exception as exc:
266
- raise HTTPException(status_code=500, detail=f'Errore agente: {exc}')
 
 
267
 
268
 
269
  @router.post('/api/public/chat')
 
2
  import os, asyncio
3
  from typing import Optional
4
  from fastapi import APIRouter, Depends, HTTPException, Request
5
+ from pydantic import BaseModel, ValidationError, field_validator
6
  from .state import _get_mem_manager, _get_executor, _get_planner
7
  from .auth_guard import require_role, AuthRole # GAP-WEBHOOK-ADMIN-FIX
8
  from .task_tool_policy import build_task_tool_policy
9
+ from .webhook_security import WebhookDeliveryStore, verify_webhook_request
10
 
11
  import logging
12
  _logger = logging.getLogger("api.webhook")
 
202
 
203
 
204
  @router.post('/api/webhook/{webhook_token}')
205
+ async def inbound_webhook(webhook_token: str, request: Request):
206
+ """Inbound webhook con token path, HMAC raw-body e idempotenza fail-closed.
207
+
208
+ Il token nel path è un selettore legacy: non è sufficiente a autorizzare il
209
+ dispatch. JSON, policy e loop sono raggiungibili soltanto dopo firma e
210
+ freshness validate sul body raw.
211
  """
212
  _expected = os.getenv('WEBHOOK_TOKEN', '').strip()
213
  if not _expected:
 
215
  if webhook_token != _expected:
216
  raise HTTPException(status_code=401, detail='Unauthorized: token non valido')
217
 
218
+ raw_body = await request.body()
219
+ verified = verify_webhook_request(raw_body, request.headers)
 
 
 
 
 
220
  try:
221
+ body = WebhookPayload.model_validate_json(raw_body)
222
+ except ValidationError as exc:
223
+ raise HTTPException(status_code=422, detail='Payload webhook non valido') from exc
224
+
225
+ # In production this is the service-role Supabase client. The security store
226
+ # fails closed if the durable RPC migration is unavailable.
227
+ from api.state import _sb as _supa
228
+ delivery_store = WebhookDeliveryStore(_supa)
229
+ claim = await delivery_store.claim(verified)
230
+ if claim.decision == 'replay' and claim.response is not None:
231
+ return claim.response
232
+ if claim.decision == 'conflict':
233
+ raise HTTPException(status_code=409, detail='X-Webhook-Id riutilizzato con payload diverso')
234
+ if claim.decision == 'in_progress':
235
+ raise HTTPException(status_code=409, detail='Webhook già in elaborazione', headers={'Retry-After': '1'})
236
+
237
+ execution_started = False
238
+ try:
239
+ _task_policy = build_task_tool_policy(body.goal)
240
+ if _task_policy.literal_response:
241
+ response = {
242
+ 'ok': True, 'output': _task_policy.literal_response, 'engine': 'policy',
243
+ 'goal': body.goal, 'steps': 0,
244
+ }
245
+ await delivery_store.complete(verified, response)
246
+ return response
247
+
248
  from agents.unified_loop import UnifiedAgentLoop
249
  from models.ai_client import AIClient
250
  client = AIClient()
 
263
  context_str = '\n'.join(m.get('content', '') for m in body.context) if body.context else ''
264
  _wh_task_id = f"wh_{webhook_token[:6]}_{int(__import__('time').time()*1000)%999983:x}"
265
  await _tg_start(_wh_task_id, body.goal)
266
+ # Da qui il loop può produrre side effect: un errore conserva il claim
267
+ # fino al TTL invece di consentire una nuova esecuzione duplicata.
268
+ execution_started = True
269
  try:
270
  result = await asyncio.wait_for(
271
  loop.run(goal=body.goal, context=context_str,
 
281
  raise HTTPException(status_code=500, detail=f'Errore agente: {exc}')
282
  _out = result.get('output', '')
283
  asyncio.ensure_future(_tg_done(_wh_task_id, body.goal, _out[:500]))
284
+ response = {
285
  'ok': result.get('success', False),
286
  'output': _out,
287
  'engine': result.get('engine', 'unknown'),
288
  'goal': body.goal,
289
  'steps': len(result.get('steps', [])),
290
  }
291
+ await delivery_store.complete(verified, response)
292
+ return response
293
+ except HTTPException:
294
+ if not execution_started:
295
+ await delivery_store.release(verified)
296
  raise
297
  except Exception as exc:
298
+ if not execution_started:
299
+ await delivery_store.release(verified)
300
+ raise HTTPException(status_code=500, detail=f'Errore agente: {exc}') from exc
301
 
302
 
303
  @router.post('/api/public/chat')
api/webhook_security.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fail-closed security primitives for the generic inbound webhook.
2
+
3
+ The authenticated byte sequence is exactly ``v1.<timestamp>.<event_id>.`` + raw body.
4
+ No JSON parsing or task dispatch is allowed before signature and freshness checks pass.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from dataclasses import dataclass
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import os
14
+ import re
15
+ import time
16
+ from typing import Any, Mapping
17
+
18
+ from fastapi import HTTPException
19
+
20
+ _EVENT_ID_RE = re.compile(r"^[A-Za-z0-9._-]{16,128}$")
21
+ _SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class VerifiedWebhook:
26
+ event_id: str
27
+ timestamp: int
28
+ payload_sha256: str
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class DeliveryClaim:
33
+ decision: str # claimed | replay | conflict | in_progress
34
+ response: dict[str, Any] | None = None
35
+
36
+
37
+ def _env_positive_int(name: str, default: int, maximum: int) -> int:
38
+ try:
39
+ value = int(os.getenv(name, str(default)))
40
+ except ValueError:
41
+ return default
42
+ return max(1, min(value, maximum))
43
+
44
+
45
+ def _signing_bytes(timestamp: str, event_id: str, raw_body: bytes) -> bytes:
46
+ return b"v1." + timestamp.encode("ascii") + b"." + event_id.encode("ascii") + b"." + raw_body
47
+
48
+
49
+ def verify_webhook_request(
50
+ raw_body: bytes,
51
+ headers: Mapping[str, str],
52
+ *,
53
+ now: int | None = None,
54
+ ) -> VerifiedWebhook:
55
+ """Validate required headers and HMAC before parsing the JSON body."""
56
+ secret = os.getenv("WEBHOOK_HMAC_SECRET", "")
57
+ if len(secret) < 32:
58
+ raise HTTPException(status_code=503, detail="Webhook HMAC non configurato")
59
+
60
+ max_body = _env_positive_int("WEBHOOK_MAX_BODY_BYTES", 262_144, 1_048_576)
61
+ if not raw_body or len(raw_body) > max_body:
62
+ raise HTTPException(status_code=413, detail="Payload webhook non valido o troppo grande")
63
+
64
+ event_id = (headers.get("x-webhook-id") or "").strip()
65
+ timestamp_raw = (headers.get("x-webhook-timestamp") or "").strip()
66
+ signature = (headers.get("x-webhook-signature") or "").strip().lower()
67
+ if not _EVENT_ID_RE.fullmatch(event_id):
68
+ raise HTTPException(status_code=400, detail="X-Webhook-Id non valido")
69
+ if not timestamp_raw.isdigit() or len(timestamp_raw) > 12:
70
+ raise HTTPException(status_code=400, detail="X-Webhook-Timestamp non valido")
71
+ if not signature.startswith("sha256=") or not _SHA256_HEX_RE.fullmatch(signature[7:]):
72
+ raise HTTPException(status_code=401, detail="Firma webhook non valida")
73
+
74
+ timestamp = int(timestamp_raw)
75
+ current = int(time.time()) if now is None else int(now)
76
+ max_age = _env_positive_int("WEBHOOK_MAX_AGE_SECONDS", 300, 3_600)
77
+ if abs(current - timestamp) > max_age:
78
+ raise HTTPException(status_code=401, detail="Timestamp webhook scaduto o non valido")
79
+
80
+ expected = hmac.new(
81
+ secret.encode("utf-8"), _signing_bytes(timestamp_raw, event_id, raw_body), hashlib.sha256
82
+ ).hexdigest()
83
+ if not hmac.compare_digest(expected, signature[7:]):
84
+ raise HTTPException(status_code=401, detail="Firma webhook non valida")
85
+
86
+ return VerifiedWebhook(
87
+ event_id=event_id,
88
+ timestamp=timestamp,
89
+ payload_sha256=hashlib.sha256(raw_body).hexdigest(),
90
+ )
91
+
92
+
93
+ class WebhookDeliveryStore:
94
+ """Atomic delivery state backed by Supabase RPC or explicit test-only memory."""
95
+
96
+ _memory_lock = asyncio.Lock()
97
+ _memory: dict[str, dict[str, Any]] = {}
98
+
99
+ def __init__(self, supabase_client: Any | None) -> None:
100
+ self._supabase = supabase_client
101
+ self._require_durable = os.getenv("WEBHOOK_IDEMPOTENCY_REQUIRE_DURABLE", "true").lower() not in {"0", "false", "no"}
102
+ self._ttl_seconds = _env_positive_int("WEBHOOK_IDEMPOTENCY_TTL_SECONDS", 86_400, 604_800)
103
+
104
+ @staticmethod
105
+ def _rpc_data(result: Any) -> dict[str, Any]:
106
+ data = getattr(result, "data", result)
107
+ if isinstance(data, list):
108
+ data = data[0] if data else None
109
+ if not isinstance(data, dict):
110
+ raise RuntimeError("invalid webhook idempotency RPC response")
111
+ return data
112
+
113
+ async def claim(self, verified: VerifiedWebhook) -> DeliveryClaim:
114
+ if self._supabase is not None:
115
+ try:
116
+ result = self._supabase.rpc("claim_webhook_delivery", {
117
+ "p_event_id": verified.event_id,
118
+ "p_payload_sha256": verified.payload_sha256,
119
+ "p_ttl_seconds": self._ttl_seconds,
120
+ }).execute()
121
+ data = self._rpc_data(result)
122
+ decision = str(data.get("decision", ""))
123
+ if decision not in {"claimed", "replay", "conflict", "in_progress"}:
124
+ raise RuntimeError("unknown webhook idempotency decision")
125
+ response = data.get("response")
126
+ return DeliveryClaim(decision, response if isinstance(response, dict) else None)
127
+ except Exception as exc:
128
+ raise HTTPException(status_code=503, detail="Store idempotency webhook non disponibile") from exc
129
+
130
+ if self._require_durable:
131
+ raise HTTPException(status_code=503, detail="Store idempotency webhook non configurato")
132
+
133
+ now = time.monotonic()
134
+ async with self._memory_lock:
135
+ expired = [key for key, value in self._memory.items() if value["expires_at"] <= now]
136
+ for key in expired:
137
+ self._memory.pop(key, None)
138
+ existing = self._memory.get(verified.event_id)
139
+ if existing is None:
140
+ self._memory[verified.event_id] = {
141
+ "payload_sha256": verified.payload_sha256,
142
+ "status": "processing",
143
+ "response": None,
144
+ "expires_at": now + self._ttl_seconds,
145
+ }
146
+ return DeliveryClaim("claimed")
147
+ if existing["payload_sha256"] != verified.payload_sha256:
148
+ return DeliveryClaim("conflict")
149
+ if existing["status"] == "completed":
150
+ return DeliveryClaim("replay", existing["response"])
151
+ return DeliveryClaim("in_progress")
152
+
153
+ async def complete(self, verified: VerifiedWebhook, response: dict[str, Any]) -> None:
154
+ if self._supabase is not None:
155
+ try:
156
+ self._supabase.rpc("complete_webhook_delivery", {
157
+ "p_event_id": verified.event_id,
158
+ "p_payload_sha256": verified.payload_sha256,
159
+ "p_response": response,
160
+ }).execute()
161
+ return
162
+ except Exception as exc:
163
+ raise HTTPException(status_code=503, detail="Store idempotency webhook non disponibile") from exc
164
+
165
+ if self._require_durable:
166
+ raise HTTPException(status_code=503, detail="Store idempotency webhook non configurato")
167
+ async with self._memory_lock:
168
+ entry = self._memory.get(verified.event_id)
169
+ if entry and entry["payload_sha256"] == verified.payload_sha256:
170
+ entry["status"] = "completed"
171
+ entry["response"] = json.loads(json.dumps(response))
172
+
173
+ async def release(self, verified: VerifiedWebhook) -> None:
174
+ if self._supabase is not None:
175
+ try:
176
+ self._supabase.rpc("release_webhook_delivery", {
177
+ "p_event_id": verified.event_id,
178
+ "p_payload_sha256": verified.payload_sha256,
179
+ }).execute()
180
+ return
181
+ except Exception:
182
+ return
183
+ if self._require_durable:
184
+ return
185
+ async with self._memory_lock:
186
+ entry = self._memory.get(verified.event_id)
187
+ if entry and entry["payload_sha256"] == verified.payload_sha256 and entry["status"] == "processing":
188
+ self._memory.pop(verified.event_id, None)
189
+
190
+ @classmethod
191
+ async def reset_memory_for_test(cls) -> None:
192
+ async with cls._memory_lock:
193
+ cls._memory.clear()
tests/test_entrypoint_policy_contracts.py CHANGED
@@ -7,12 +7,18 @@ prima di provider, notifiche, planner e tool.
7
  from __future__ import annotations
8
 
9
  import asyncio
 
 
 
10
  import os
 
11
  import sys
12
  import types
13
  import unittest
14
  from unittest.mock import patch
15
 
 
 
16
  _BACKEND = os.path.join(os.path.dirname(__file__), "..")
17
  if _BACKEND not in sys.path:
18
  sys.path.insert(0, _BACKEND)
@@ -30,6 +36,15 @@ class _Request:
30
  self.headers = headers or {}
31
 
32
 
 
 
 
 
 
 
 
 
 
33
  class _CallbackRequest(_Request):
34
  async def json(self) -> dict[str, object]:
35
  raise AssertionError("bad secret must not parse payload")
@@ -71,9 +86,29 @@ async def _noop(*_args: object, **_kwargs: object) -> None:
71
  return None
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  class EntryPointPolicyContracts(unittest.IsolatedAsyncioTestCase):
75
- def setUp(self) -> None:
 
76
  _RecordingLoop.calls.clear()
 
77
 
78
  async def test_telegram_callback_fails_closed_before_payload_or_outbound_work(self) -> None:
79
  from api import webhook
@@ -85,12 +120,19 @@ class EntryPointPolicyContracts(unittest.IsolatedAsyncioTestCase):
85
 
86
  async def test_webhook_literal_contract_returns_before_loop_or_notification(self) -> None:
87
  from api import webhook
 
88
 
89
- payload = webhook.WebhookPayload(goal=LITERAL_PROMPT)
90
- with patch.dict(os.environ, {"WEBHOOK_TOKEN": "test-webhook-token"}, clear=False), \
 
 
 
 
 
 
91
  patch.object(webhook, "_tg_start", side_effect=AssertionError("notification forbidden")), \
92
  patch.object(webhook, "_get_mem_manager", side_effect=AssertionError("provider forbidden")):
93
- result = await webhook.inbound_webhook("test-webhook-token", payload)
94
 
95
  self.assertEqual(result["output"], "TEST_E2E_OK")
96
  self.assertEqual(result["steps"], 0)
@@ -114,17 +156,25 @@ class EntryPointPolicyContracts(unittest.IsolatedAsyncioTestCase):
114
 
115
  async def test_webhook_and_public_chat_propagate_no_tool_policy_to_loop(self) -> None:
116
  from api import webhook
 
117
 
118
  fake_modules = _fake_loop_modules()
 
 
 
 
 
 
119
  with patch.dict(sys.modules, fake_modules), \
120
- patch.dict(os.environ, {"WEBHOOK_TOKEN": "test-webhook-token", "PUBLIC_API_TOKEN": "public-test-token"}, clear=False), \
 
121
  patch.object(webhook, "_get_mem_manager", return_value=object()), \
122
  patch.object(webhook, "_get_executor", return_value=object()), \
123
  patch.object(webhook, "_get_planner", return_value=object()), \
124
  patch.object(webhook, "_tg_start", _noop), \
125
  patch.object(webhook, "_tg_done", _noop):
126
  webhook_result = await webhook.inbound_webhook(
127
- "test-webhook-token", webhook.WebhookPayload(goal=NO_TOOL_PROMPT)
128
  )
129
  public_result = await webhook.public_chat(
130
  webhook.PublicChatPayload(message=NO_TOOL_PROMPT),
@@ -136,6 +186,44 @@ class EntryPointPolicyContracts(unittest.IsolatedAsyncioTestCase):
136
  self.assertEqual(len(_RecordingLoop.calls), 2)
137
  self.assertTrue(all(call["allow_tools"] is False for call in _RecordingLoop.calls))
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  async def test_scheduler_literal_contract_skips_provider_initialization(self) -> None:
140
  from api import scheduler
141
 
 
7
  from __future__ import annotations
8
 
9
  import asyncio
10
+ import hashlib
11
+ import hmac
12
+ import json
13
  import os
14
+ import time
15
  import sys
16
  import types
17
  import unittest
18
  from unittest.mock import patch
19
 
20
+ from fastapi import HTTPException
21
+
22
  _BACKEND = os.path.join(os.path.dirname(__file__), "..")
23
  if _BACKEND not in sys.path:
24
  sys.path.insert(0, _BACKEND)
 
36
  self.headers = headers or {}
37
 
38
 
39
+ class _WebhookRequest(_Request):
40
+ def __init__(self, raw_body: bytes, headers: dict[str, str]) -> None:
41
+ super().__init__(headers)
42
+ self._raw_body = raw_body
43
+
44
+ async def body(self) -> bytes:
45
+ return self._raw_body
46
+
47
+
48
  class _CallbackRequest(_Request):
49
  async def json(self) -> dict[str, object]:
50
  raise AssertionError("bad secret must not parse payload")
 
86
  return None
87
 
88
 
89
+ def _signed_webhook_request(
90
+ payload: dict[str, object],
91
+ *,
92
+ event_id: str = "evt-000000000001",
93
+ timestamp: int | None = None,
94
+ signature: str | None = None,
95
+ ) -> _WebhookRequest:
96
+ raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
97
+ ts = str(int(time.time()) if timestamp is None else timestamp)
98
+ signing = b"v1." + ts.encode("ascii") + b"." + event_id.encode("ascii") + b"." + raw
99
+ expected = hmac.new(b"h" * 32, signing, hashlib.sha256).hexdigest()
100
+ return _WebhookRequest(raw, {
101
+ "x-webhook-id": event_id,
102
+ "x-webhook-timestamp": ts,
103
+ "x-webhook-signature": signature or f"sha256={expected}",
104
+ })
105
+
106
+
107
  class EntryPointPolicyContracts(unittest.IsolatedAsyncioTestCase):
108
+ async def asyncSetUp(self) -> None:
109
+ from api.webhook_security import WebhookDeliveryStore
110
  _RecordingLoop.calls.clear()
111
+ await WebhookDeliveryStore.reset_memory_for_test()
112
 
113
  async def test_telegram_callback_fails_closed_before_payload_or_outbound_work(self) -> None:
114
  from api import webhook
 
120
 
121
  async def test_webhook_literal_contract_returns_before_loop_or_notification(self) -> None:
122
  from api import webhook
123
+ import api.state as state
124
 
125
+ request = _signed_webhook_request({"goal": LITERAL_PROMPT})
126
+ env = {
127
+ "WEBHOOK_TOKEN": "test-webhook-token",
128
+ "WEBHOOK_HMAC_SECRET": "h" * 32,
129
+ "WEBHOOK_IDEMPOTENCY_REQUIRE_DURABLE": "false",
130
+ }
131
+ with patch.dict(os.environ, env, clear=False), \
132
+ patch.object(state, "_sb", None), \
133
  patch.object(webhook, "_tg_start", side_effect=AssertionError("notification forbidden")), \
134
  patch.object(webhook, "_get_mem_manager", side_effect=AssertionError("provider forbidden")):
135
+ result = await webhook.inbound_webhook("test-webhook-token", request)
136
 
137
  self.assertEqual(result["output"], "TEST_E2E_OK")
138
  self.assertEqual(result["steps"], 0)
 
156
 
157
  async def test_webhook_and_public_chat_propagate_no_tool_policy_to_loop(self) -> None:
158
  from api import webhook
159
+ import api.state as state
160
 
161
  fake_modules = _fake_loop_modules()
162
+ env = {
163
+ "WEBHOOK_TOKEN": "test-webhook-token",
164
+ "PUBLIC_API_TOKEN": "public-test-token",
165
+ "WEBHOOK_HMAC_SECRET": "h" * 32,
166
+ "WEBHOOK_IDEMPOTENCY_REQUIRE_DURABLE": "false",
167
+ }
168
  with patch.dict(sys.modules, fake_modules), \
169
+ patch.dict(os.environ, env, clear=False), \
170
+ patch.object(state, "_sb", None), \
171
  patch.object(webhook, "_get_mem_manager", return_value=object()), \
172
  patch.object(webhook, "_get_executor", return_value=object()), \
173
  patch.object(webhook, "_get_planner", return_value=object()), \
174
  patch.object(webhook, "_tg_start", _noop), \
175
  patch.object(webhook, "_tg_done", _noop):
176
  webhook_result = await webhook.inbound_webhook(
177
+ "test-webhook-token", _signed_webhook_request({"goal": NO_TOOL_PROMPT})
178
  )
179
  public_result = await webhook.public_chat(
180
  webhook.PublicChatPayload(message=NO_TOOL_PROMPT),
 
186
  self.assertEqual(len(_RecordingLoop.calls), 2)
187
  self.assertTrue(all(call["allow_tools"] is False for call in _RecordingLoop.calls))
188
 
189
+ async def test_webhook_replay_conflict_and_expired_timestamp_are_rejected_before_dispatch(self) -> None:
190
+ from api import webhook
191
+ import api.state as state
192
+
193
+ env = {
194
+ "WEBHOOK_TOKEN": "test-webhook-token",
195
+ "WEBHOOK_HMAC_SECRET": "h" * 32,
196
+ "WEBHOOK_IDEMPOTENCY_REQUIRE_DURABLE": "false",
197
+ "WEBHOOK_MAX_AGE_SECONDS": "300",
198
+ }
199
+ first = _signed_webhook_request({"goal": LITERAL_PROMPT}, event_id="evt-replay-000001")
200
+ duplicate = _signed_webhook_request({"goal": LITERAL_PROMPT}, event_id="evt-replay-000001")
201
+ conflict = _signed_webhook_request({"goal": LITERAL_PROMPT, "context": [{"content": "different"}]}, event_id="evt-replay-000001")
202
+ expired = _signed_webhook_request({"goal": LITERAL_PROMPT}, event_id="evt-expired-0001", timestamp=int(time.time()) - 301)
203
+ with patch.dict(os.environ, env, clear=False), patch.object(state, "_sb", None):
204
+ first_response = await webhook.inbound_webhook("test-webhook-token", first)
205
+ duplicate_response = await webhook.inbound_webhook("test-webhook-token", duplicate)
206
+ with self.assertRaises(HTTPException) as conflict_error:
207
+ await webhook.inbound_webhook("test-webhook-token", conflict)
208
+ with self.assertRaises(HTTPException) as expired_error:
209
+ await webhook.inbound_webhook("test-webhook-token", expired)
210
+
211
+ self.assertEqual(first_response, duplicate_response)
212
+ self.assertEqual(conflict_error.exception.status_code, 409)
213
+ self.assertEqual(expired_error.exception.status_code, 401)
214
+
215
+ async def test_webhook_bad_signature_is_rejected_before_pydantic_parse(self) -> None:
216
+ from api import webhook
217
+
218
+ request = _signed_webhook_request({"goal": LITERAL_PROMPT}, signature="sha256=" + "0" * 64)
219
+ env = {"WEBHOOK_TOKEN": "test-webhook-token", "WEBHOOK_HMAC_SECRET": "h" * 32}
220
+ with patch.dict(os.environ, env, clear=False), \
221
+ patch.object(webhook.WebhookPayload, "model_validate_json", side_effect=AssertionError("must not parse")):
222
+ with self.assertRaises(HTTPException) as signature_error:
223
+ await webhook.inbound_webhook("test-webhook-token", request)
224
+
225
+ self.assertEqual(signature_error.exception.status_code, 401)
226
+
227
  async def test_scheduler_literal_contract_skips_provider_initialization(self) -> None:
228
  from api import scheduler
229
 
tests/test_webhook_security.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contratti di sicurezza del webhook HMAC e della sua idempotenza."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import hmac
6
+ import os
7
+ import sys
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ from fastapi import HTTPException
12
+
13
+ _BACKEND = os.path.join(os.path.dirname(__file__), "..")
14
+ if _BACKEND not in sys.path:
15
+ sys.path.insert(0, _BACKEND)
16
+
17
+ from api.webhook_security import ( # noqa: E402
18
+ WebhookDeliveryStore,
19
+ _signing_bytes,
20
+ verify_webhook_request,
21
+ )
22
+
23
+ _SECRET = "s" * 32
24
+ _EVENT_ID = "evt-security-0001"
25
+ _NOW = 1_700_000_000
26
+
27
+
28
+ def _headers(raw: bytes, *, event_id: str = _EVENT_ID, timestamp: int = _NOW) -> dict[str, str]:
29
+ timestamp_raw = str(timestamp)
30
+ digest = hmac.new(
31
+ _SECRET.encode("utf-8"),
32
+ _signing_bytes(timestamp_raw, event_id, raw),
33
+ hashlib.sha256,
34
+ ).hexdigest()
35
+ return {
36
+ "x-webhook-id": event_id,
37
+ "x-webhook-timestamp": timestamp_raw,
38
+ "x-webhook-signature": f"sha256={digest}",
39
+ }
40
+
41
+
42
+ class WebhookSecurityTests(unittest.IsolatedAsyncioTestCase):
43
+ async def asyncSetUp(self) -> None:
44
+ await WebhookDeliveryStore.reset_memory_for_test()
45
+
46
+ def test_signature_binds_the_exact_raw_bytes_and_fresh_timestamp(self) -> None:
47
+ raw = b'{"goal":"safe", "context":[]}'
48
+ with patch.dict(os.environ, {"WEBHOOK_HMAC_SECRET": _SECRET}, clear=False):
49
+ verified = verify_webhook_request(raw, _headers(raw), now=_NOW)
50
+ self.assertEqual(verified.event_id, _EVENT_ID)
51
+ self.assertEqual(verified.payload_sha256, hashlib.sha256(raw).hexdigest())
52
+ with self.assertRaises(HTTPException) as altered:
53
+ verify_webhook_request(b'{"goal":"safe","context":[]}', _headers(raw), now=_NOW)
54
+ with self.assertRaises(HTTPException) as future:
55
+ verify_webhook_request(raw, _headers(raw, timestamp=_NOW + 301), now=_NOW)
56
+
57
+ self.assertEqual(altered.exception.status_code, 401)
58
+ self.assertEqual(future.exception.status_code, 401)
59
+
60
+ def test_required_headers_and_secret_fail_closed(self) -> None:
61
+ raw = b'{"goal":"safe"}'
62
+ with patch.dict(os.environ, {"WEBHOOK_HMAC_SECRET": "too-short"}, clear=False):
63
+ with self.assertRaises(HTTPException) as missing_secret:
64
+ verify_webhook_request(raw, _headers(raw), now=_NOW)
65
+ with patch.dict(os.environ, {"WEBHOOK_HMAC_SECRET": _SECRET}, clear=False):
66
+ malformed = _headers(raw)
67
+ malformed["x-webhook-id"] = "short"
68
+ with self.assertRaises(HTTPException) as invalid_id:
69
+ verify_webhook_request(raw, malformed, now=_NOW)
70
+ missing_signature = _headers(raw)
71
+ missing_signature.pop("x-webhook-signature")
72
+ with self.assertRaises(HTTPException) as unsigned:
73
+ verify_webhook_request(raw, missing_signature, now=_NOW)
74
+
75
+ self.assertEqual(missing_secret.exception.status_code, 503)
76
+ self.assertEqual(invalid_id.exception.status_code, 400)
77
+ self.assertEqual(unsigned.exception.status_code, 401)
78
+
79
+ async def test_memory_state_machine_caches_replay_conflicts_and_releases_pre_dispatch(self) -> None:
80
+ raw = b'{"goal":"safe"}'
81
+ other_raw = b'{"goal":"other"}'
82
+ with patch.dict(
83
+ os.environ,
84
+ {"WEBHOOK_HMAC_SECRET": _SECRET, "WEBHOOK_IDEMPOTENCY_REQUIRE_DURABLE": "false"},
85
+ clear=False,
86
+ ):
87
+ verified = verify_webhook_request(raw, _headers(raw), now=_NOW)
88
+ conflicting = verify_webhook_request(other_raw, _headers(other_raw), now=_NOW)
89
+ store = WebhookDeliveryStore(None)
90
+ self.assertEqual((await store.claim(verified)).decision, "claimed")
91
+ self.assertEqual((await store.claim(verified)).decision, "in_progress")
92
+ self.assertEqual((await store.claim(conflicting)).decision, "conflict")
93
+ await store.complete(verified, {"ok": True, "output": "cached"})
94
+ replay = await store.claim(verified)
95
+ self.assertEqual(replay.decision, "replay")
96
+ self.assertEqual(replay.response, {"ok": True, "output": "cached"})
97
+
98
+ release_id = "evt-release-00001"
99
+ releasing = verify_webhook_request(raw, _headers(raw, event_id=release_id), now=_NOW)
100
+ self.assertEqual((await store.claim(releasing)).decision, "claimed")
101
+ await store.release(releasing)
102
+ self.assertEqual((await store.claim(releasing)).decision, "claimed")
103
+
104
+ async def test_durable_store_is_required_by_default_and_rpc_errors_are_unavailable(self) -> None:
105
+ raw = b'{"goal":"safe"}'
106
+ with patch.dict(os.environ, {"WEBHOOK_HMAC_SECRET": _SECRET, "WEBHOOK_IDEMPOTENCY_REQUIRE_DURABLE": "true"}, clear=False):
107
+ verified = verify_webhook_request(raw, _headers(raw), now=_NOW)
108
+ with self.assertRaises(HTTPException) as absent_store:
109
+ await WebhookDeliveryStore(None).claim(verified)
110
+ with self.assertRaises(HTTPException) as broken_store:
111
+ await WebhookDeliveryStore(_BrokenSupabase()).claim(verified)
112
+
113
+ self.assertEqual(absent_store.exception.status_code, 503)
114
+ self.assertEqual(broken_store.exception.status_code, 503)
115
+
116
+
117
+ class _BrokenSupabase:
118
+ def rpc(self, *_args: object, **_kwargs: object) -> object:
119
+ raise RuntimeError("database unavailable")
120
+
121
+
122
+ if __name__ == "__main__":
123
+ unittest.main()