/ tests / gateway / test_webhook_adapter.py
test_webhook_adapter.py
  1  """Unit tests for the generic webhook platform adapter.
  2  
  3  Covers:
  4  - HMAC signature validation (GitHub, GitLab, generic)
  5  - Prompt rendering with dot-notation template variables
  6  - Event type filtering
  7  - HTTP handler behaviour (404, 202, health)
  8  - Idempotency cache (duplicate delivery IDs)
  9  - Rate limiting (fixed-window, per route)
 10  - Body size limits
 11  - INSECURE_NO_AUTH bypass
 12  - Session isolation for concurrent webhooks
 13  - Delivery info cleanup after send()
 14  - connect / disconnect lifecycle
 15  """
 16  
 17  import asyncio
 18  import hashlib
 19  import hmac
 20  import json
 21  import time
 22  from unittest.mock import AsyncMock, MagicMock, patch
 23  
 24  import pytest
 25  from aiohttp import web
 26  from aiohttp.test_utils import TestClient, TestServer
 27  
 28  from gateway.config import Platform, PlatformConfig
 29  from gateway.platforms.base import MessageEvent, MessageType, SendResult
 30  from gateway.platforms.webhook import (
 31      WebhookAdapter,
 32      _INSECURE_NO_AUTH,
 33      check_webhook_requirements,
 34  )
 35  
 36  
 37  # ---------------------------------------------------------------------------
 38  # Helpers
 39  # ---------------------------------------------------------------------------
 40  
 41  def _make_config(
 42      routes=None,
 43      secret="",
 44      rate_limit=30,
 45      max_body_bytes=1_048_576,
 46      host="0.0.0.0",
 47      port=0,  # let OS pick a free port in tests
 48  ):
 49      """Build a PlatformConfig suitable for WebhookAdapter."""
 50      extra = {
 51          "host": host,
 52          "port": port,
 53          "routes": routes or {},
 54          "rate_limit": rate_limit,
 55          "max_body_bytes": max_body_bytes,
 56      }
 57      if secret:
 58          extra["secret"] = secret
 59      return PlatformConfig(enabled=True, extra=extra)
 60  
 61  
 62  def _make_adapter(routes=None, **kwargs):
 63      """Create a WebhookAdapter with sensible defaults for testing."""
 64      config = _make_config(routes=routes, **kwargs)
 65      return WebhookAdapter(config)
 66  
 67  
 68  def _create_app(adapter: WebhookAdapter) -> web.Application:
 69      """Build the aiohttp Application from the adapter (without starting a full server)."""
 70      app = web.Application()
 71      app.router.add_get("/health", adapter._handle_health)
 72      app.router.add_post("/webhooks/{route_name}", adapter._handle_webhook)
 73      return app
 74  
 75  
 76  def _mock_request(headers=None, body=b"", content_length=None, match_info=None):
 77      """Build a lightweight mock aiohttp request for non-HTTP tests."""
 78      req = MagicMock()
 79      req.headers = headers or {}
 80      req.content_length = content_length if content_length is not None else len(body)
 81      req.match_info = match_info or {}
 82      req.method = "POST"
 83  
 84      async def _read():
 85          return body
 86  
 87      req.read = _read
 88      return req
 89  
 90  
 91  def _github_signature(body: bytes, secret: str) -> str:
 92      """Compute X-Hub-Signature-256 for *body* using *secret*."""
 93      return "sha256=" + hmac.new(
 94          secret.encode(), body, hashlib.sha256
 95      ).hexdigest()
 96  
 97  
 98  def _generic_signature(body: bytes, secret: str) -> str:
 99      """Compute X-Webhook-Signature (plain HMAC-SHA256 hex) for *body*."""
100      return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
101  
102  
103  # ===================================================================
104  # Signature validation
105  # ===================================================================
106  
107  
108  class TestValidateSignature:
109      """Tests for WebhookAdapter._validate_signature."""
110  
111      def test_validate_github_signature_valid(self):
112          """Valid X-Hub-Signature-256 is accepted."""
113          adapter = _make_adapter()
114          body = b'{"action": "opened"}'
115          secret = "webhook-secret-42"
116          sig = _github_signature(body, secret)
117          req = _mock_request(headers={"X-Hub-Signature-256": sig})
118          assert adapter._validate_signature(req, body, secret) is True
119  
120      def test_validate_github_signature_invalid(self):
121          """Wrong X-Hub-Signature-256 is rejected."""
122          adapter = _make_adapter()
123          body = b'{"action": "opened"}'
124          secret = "webhook-secret-42"
125          req = _mock_request(headers={"X-Hub-Signature-256": "sha256=deadbeef"})
126          assert adapter._validate_signature(req, body, secret) is False
127  
128      def test_validate_gitlab_token(self):
129          """GitLab plain-token match via X-Gitlab-Token."""
130          adapter = _make_adapter()
131          secret = "gl-token-value"
132          req = _mock_request(headers={"X-Gitlab-Token": secret})
133          assert adapter._validate_signature(req, b"{}", secret) is True
134  
135      def test_validate_gitlab_token_wrong(self):
136          """Wrong X-Gitlab-Token is rejected."""
137          adapter = _make_adapter()
138          req = _mock_request(headers={"X-Gitlab-Token": "wrong"})
139          assert adapter._validate_signature(req, b"{}", "correct") is False
140  
141      def test_validate_no_signature_with_secret_rejects(self):
142          """Secret configured but no recognised signature header → reject."""
143          adapter = _make_adapter()
144          req = _mock_request(headers={})  # no sig headers at all
145          assert adapter._validate_signature(req, b"{}", "my-secret") is False
146  
147      def test_validate_no_secret_allows_all(self):
148          """When the secret is empty/falsy, the validator is never even called
149          by the handler (secret check is 'if secret and secret != _INSECURE...').
150          Verify that an empty secret isn't accidentally passed to the validator."""
151          # This tests the semantics: empty secret means skip validation entirely.
152          # The handler code does: if secret and secret != _INSECURE_NO_AUTH: validate
153          # So with an empty secret, _validate_signature is never reached.
154          # We just verify the code path is correct by constructing an adapter
155          # with no secret and confirming the route config resolves to "".
156          adapter = _make_adapter(
157              routes={"test": {"prompt": "hello"}},
158              secret="",
159          )
160          # The route has no secret, global secret is empty
161          route_secret = adapter._routes["test"].get("secret", adapter._global_secret)
162          assert not route_secret  # empty → validation is skipped in handler
163  
164      def test_validate_generic_signature_valid(self):
165          """Valid X-Webhook-Signature (generic HMAC-SHA256 hex) is accepted."""
166          adapter = _make_adapter()
167          body = b'{"event": "push"}'
168          secret = "generic-secret"
169          sig = _generic_signature(body, secret)
170          req = _mock_request(headers={"X-Webhook-Signature": sig})
171          assert adapter._validate_signature(req, body, secret) is True
172  
173  
174  # ===================================================================
175  # Prompt rendering
176  # ===================================================================
177  
178  
179  class TestRenderPrompt:
180      """Tests for WebhookAdapter._render_prompt."""
181  
182      def test_render_prompt_dot_notation(self):
183          """Dot-notation {pull_request.title} resolves nested keys."""
184          adapter = _make_adapter()
185          payload = {"pull_request": {"title": "Fix bug", "number": 42}}
186          result = adapter._render_prompt(
187              "PR #{pull_request.number}: {pull_request.title}",
188              payload,
189              "pull_request",
190              "github",
191          )
192          assert result == "PR #42: Fix bug"
193  
194      def test_render_prompt_missing_key_preserved(self):
195          """{nonexistent} is left as-is when key doesn't exist in payload."""
196          adapter = _make_adapter()
197          result = adapter._render_prompt(
198              "Hello {nonexistent}!",
199              {"action": "opened"},
200              "push",
201              "test",
202          )
203          assert "{nonexistent}" in result
204  
205      def test_render_prompt_no_template_dumps_json(self):
206          """Empty template → JSON dump fallback with event/route context."""
207          adapter = _make_adapter()
208          payload = {"key": "value"}
209          result = adapter._render_prompt("", payload, "push", "my-route")
210          assert "push" in result
211          assert "my-route" in result
212          assert "key" in result
213  
214  
215  # ===================================================================
216  # Delivery extra rendering
217  # ===================================================================
218  
219  
220  class TestRenderDeliveryExtra:
221      def test_render_delivery_extra_templates(self):
222          """String values in deliver_extra are rendered with payload data."""
223          adapter = _make_adapter()
224          extra = {"repo": "{repository.full_name}", "pr_number": "{number}", "static": 42}
225          payload = {"repository": {"full_name": "org/repo"}, "number": 7}
226          result = adapter._render_delivery_extra(extra, payload)
227          assert result["repo"] == "org/repo"
228          assert result["pr_number"] == "7"
229          assert result["static"] == 42  # non-string left as-is
230  
231  
232  # ===================================================================
233  # Event filtering
234  # ===================================================================
235  
236  
237  class TestEventFilter:
238      """Tests for event type filtering in _handle_webhook."""
239  
240      @pytest.mark.asyncio
241      async def test_event_filter_accepts_matching(self):
242          """Matching event type passes through."""
243          routes = {
244              "gh": {
245                  "secret": _INSECURE_NO_AUTH,
246                  "events": ["pull_request"],
247                  "prompt": "PR: {action}",
248              }
249          }
250          adapter = _make_adapter(routes=routes)
251          # Stub handle_message to avoid running the agent
252          adapter.handle_message = AsyncMock()
253  
254          app = _create_app(adapter)
255          async with TestClient(TestServer(app)) as cli:
256              resp = await cli.post(
257                  "/webhooks/gh",
258                  json={"action": "opened"},
259                  headers={"X-GitHub-Event": "pull_request"},
260              )
261              assert resp.status == 202
262  
263      @pytest.mark.asyncio
264      async def test_event_filter_rejects_non_matching(self):
265          """Non-matching event type returns 200 with status=ignored."""
266          routes = {
267              "gh": {
268                  "secret": _INSECURE_NO_AUTH,
269                  "events": ["pull_request"],
270                  "prompt": "test",
271              }
272          }
273          adapter = _make_adapter(routes=routes)
274  
275          app = _create_app(adapter)
276          async with TestClient(TestServer(app)) as cli:
277              resp = await cli.post(
278                  "/webhooks/gh",
279                  json={"action": "opened"},
280                  headers={"X-GitHub-Event": "push"},
281              )
282              assert resp.status == 200
283              data = await resp.json()
284              assert data["status"] == "ignored"
285  
286      @pytest.mark.asyncio
287      async def test_event_filter_empty_allows_all(self):
288          """No events list → accept any event type."""
289          routes = {
290              "all": {
291                  "secret": _INSECURE_NO_AUTH,
292                  "prompt": "got it",
293              }
294          }
295          adapter = _make_adapter(routes=routes)
296          adapter.handle_message = AsyncMock()
297  
298          app = _create_app(adapter)
299          async with TestClient(TestServer(app)) as cli:
300              resp = await cli.post(
301                  "/webhooks/all",
302                  json={"action": "any"},
303                  headers={"X-GitHub-Event": "whatever"},
304              )
305              assert resp.status == 202
306  
307  
308  # ===================================================================
309  # HTTP handling
310  # ===================================================================
311  
312  
313  class TestHTTPHandling:
314  
315      @pytest.mark.asyncio
316      async def test_unknown_route_returns_404(self):
317          """POST to an unknown route returns 404."""
318          adapter = _make_adapter(routes={"real": {"secret": _INSECURE_NO_AUTH, "prompt": "x"}})
319          app = _create_app(adapter)
320          async with TestClient(TestServer(app)) as cli:
321              resp = await cli.post("/webhooks/nonexistent", json={"a": 1})
322              assert resp.status == 404
323  
324      @pytest.mark.asyncio
325      async def test_webhook_handler_returns_202(self):
326          """Valid request returns 202 Accepted."""
327          routes = {"test": {"secret": _INSECURE_NO_AUTH, "prompt": "hi"}}
328          adapter = _make_adapter(routes=routes)
329          adapter.handle_message = AsyncMock()
330  
331          app = _create_app(adapter)
332          async with TestClient(TestServer(app)) as cli:
333              resp = await cli.post("/webhooks/test", json={"data": "value"})
334              assert resp.status == 202
335              data = await resp.json()
336              assert data["status"] == "accepted"
337              assert data["route"] == "test"
338  
339      @pytest.mark.asyncio
340      async def test_health_endpoint(self):
341          """GET /health returns 200 with status=ok."""
342          adapter = _make_adapter()
343          app = _create_app(adapter)
344          async with TestClient(TestServer(app)) as cli:
345              resp = await cli.get("/health")
346              assert resp.status == 200
347              data = await resp.json()
348              assert data["status"] == "ok"
349              assert data["platform"] == "webhook"
350  
351      @pytest.mark.asyncio
352      async def test_connect_starts_server(self):
353          """connect() starts the HTTP listener and marks adapter as connected."""
354          routes = {"r1": {"secret": _INSECURE_NO_AUTH, "prompt": "x"}}
355          adapter = _make_adapter(routes=routes, port=0)
356          # Use port 0 — the OS picks a free port, but aiohttp requires a real bind.
357          # We just test that the method completes and marks connected.
358          # Need to mock TCPSite to avoid actual binding.
359          with patch("gateway.platforms.webhook.web.AppRunner") as MockRunner, \
360               patch("gateway.platforms.webhook.web.TCPSite") as MockSite:
361              mock_runner_inst = AsyncMock()
362              MockRunner.return_value = mock_runner_inst
363              mock_site_inst = AsyncMock()
364              MockSite.return_value = mock_site_inst
365  
366              result = await adapter.connect()
367              assert result is True
368              assert adapter.is_connected
369              mock_runner_inst.setup.assert_awaited_once()
370              mock_site_inst.start.assert_awaited_once()
371  
372          await adapter.disconnect()
373  
374      @pytest.mark.asyncio
375      async def test_disconnect_cleans_up(self):
376          """disconnect() stops the server and marks adapter disconnected."""
377          adapter = _make_adapter()
378          # Simulate a runner that was previously set up
379          mock_runner = AsyncMock()
380          adapter._runner = mock_runner
381          adapter._running = True
382  
383          await adapter.disconnect()
384          mock_runner.cleanup.assert_awaited_once()
385          assert adapter._runner is None
386          assert not adapter.is_connected
387  
388  
389  # ===================================================================
390  # Idempotency
391  # ===================================================================
392  
393  
394  class TestIdempotency:
395  
396      @pytest.mark.asyncio
397      async def test_duplicate_delivery_id_returns_200(self):
398          """Second request with same delivery ID returns 200 duplicate."""
399          routes = {"idem": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}}
400          adapter = _make_adapter(routes=routes)
401          adapter.handle_message = AsyncMock()
402  
403          app = _create_app(adapter)
404          async with TestClient(TestServer(app)) as cli:
405              headers = {"X-GitHub-Delivery": "delivery-123"}
406              resp1 = await cli.post("/webhooks/idem", json={"a": 1}, headers=headers)
407              assert resp1.status == 202
408  
409              resp2 = await cli.post("/webhooks/idem", json={"a": 1}, headers=headers)
410              assert resp2.status == 200
411              data = await resp2.json()
412              assert data["status"] == "duplicate"
413  
414      @pytest.mark.asyncio
415      async def test_expired_delivery_id_allows_reprocess(self):
416          """After TTL expires, the same delivery ID is accepted again."""
417          routes = {"idem": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}}
418          adapter = _make_adapter(routes=routes)
419          adapter._idempotency_ttl = 1  # 1 second TTL for test speed
420          adapter.handle_message = AsyncMock()
421  
422          app = _create_app(adapter)
423          async with TestClient(TestServer(app)) as cli:
424              headers = {"X-GitHub-Delivery": "delivery-456"}
425  
426              resp1 = await cli.post("/webhooks/idem", json={"x": 1}, headers=headers)
427              assert resp1.status == 202
428  
429              # Backdate the cache entry so it appears expired
430              adapter._seen_deliveries["delivery-456"] = time.time() - 3700
431  
432              resp2 = await cli.post("/webhooks/idem", json={"x": 1}, headers=headers)
433              assert resp2.status == 202  # re-accepted
434  
435  
436  # ===================================================================
437  # Rate limiting
438  # ===================================================================
439  
440  
441  class TestRateLimiting:
442  
443      @pytest.mark.asyncio
444      async def test_rate_limit_rejects_excess(self):
445          """Exceeding the rate limit returns 429."""
446          routes = {"limited": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}}
447          adapter = _make_adapter(routes=routes, rate_limit=2)
448          adapter.handle_message = AsyncMock()
449  
450          app = _create_app(adapter)
451          async with TestClient(TestServer(app)) as cli:
452              # Two requests within limit
453              for i in range(2):
454                  resp = await cli.post(
455                      "/webhooks/limited",
456                      json={"n": i},
457                      headers={"X-GitHub-Delivery": f"d-{i}"},
458                  )
459                  assert resp.status == 202, f"Request {i} should be accepted"
460  
461              # Third request should be rate-limited
462              resp = await cli.post(
463                  "/webhooks/limited",
464                  json={"n": 99},
465                  headers={"X-GitHub-Delivery": "d-99"},
466              )
467              assert resp.status == 429
468  
469      @pytest.mark.asyncio
470      async def test_rate_limit_window_resets(self):
471          """After the 60-second window passes, requests are allowed again."""
472          routes = {"limited": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}}
473          adapter = _make_adapter(routes=routes, rate_limit=1)
474          adapter.handle_message = AsyncMock()
475  
476          app = _create_app(adapter)
477          async with TestClient(TestServer(app)) as cli:
478              resp = await cli.post(
479                  "/webhooks/limited",
480                  json={"n": 1},
481                  headers={"X-GitHub-Delivery": "d-a"},
482              )
483              assert resp.status == 202
484  
485              # Backdate all rate-limit timestamps to > 60 seconds ago
486              adapter._rate_counts["limited"] = [time.time() - 120]
487  
488              resp = await cli.post(
489                  "/webhooks/limited",
490                  json={"n": 2},
491                  headers={"X-GitHub-Delivery": "d-b"},
492              )
493              assert resp.status == 202  # allowed again
494  
495  
496  # ===================================================================
497  # Body size limit
498  # ===================================================================
499  
500  
501  class TestBodySize:
502  
503      @pytest.mark.asyncio
504      async def test_oversized_payload_rejected(self):
505          """Content-Length > max_body_bytes returns 413."""
506          routes = {"big": {"secret": _INSECURE_NO_AUTH, "prompt": "test"}}
507          adapter = _make_adapter(routes=routes, max_body_bytes=100)
508  
509          app = _create_app(adapter)
510          async with TestClient(TestServer(app)) as cli:
511              large_payload = {"data": "x" * 200}
512              resp = await cli.post(
513                  "/webhooks/big",
514                  json=large_payload,
515                  headers={"Content-Length": "999999"},
516              )
517              assert resp.status == 413
518  
519  
520  # ===================================================================
521  # INSECURE_NO_AUTH
522  # ===================================================================
523  
524  
525  class TestInsecureNoAuth:
526  
527      @pytest.mark.asyncio
528      async def test_insecure_no_auth_skips_validation(self):
529          """Setting secret to _INSECURE_NO_AUTH bypasses signature check."""
530          routes = {"open": {"secret": _INSECURE_NO_AUTH, "prompt": "hello"}}
531          adapter = _make_adapter(routes=routes)
532          adapter.handle_message = AsyncMock()
533  
534          app = _create_app(adapter)
535          async with TestClient(TestServer(app)) as cli:
536              # No signature header at all — should still be accepted
537              resp = await cli.post("/webhooks/open", json={"test": True})
538              assert resp.status == 202
539  
540  
541  # ===================================================================
542  # Session isolation
543  # ===================================================================
544  
545  
546  class TestSessionIsolation:
547  
548      @pytest.mark.asyncio
549      async def test_concurrent_webhooks_get_independent_sessions(self):
550          """Two events on the same route produce different session keys."""
551          routes = {"ci": {"secret": _INSECURE_NO_AUTH, "prompt": "build"}}
552          adapter = _make_adapter(routes=routes)
553  
554          captured_events = []
555  
556          async def _capture(event):
557              captured_events.append(event)
558  
559          adapter.handle_message = _capture
560  
561          app = _create_app(adapter)
562          async with TestClient(TestServer(app)) as cli:
563              resp1 = await cli.post(
564                  "/webhooks/ci",
565                  json={"ref": "main"},
566                  headers={"X-GitHub-Delivery": "aaa-111"},
567              )
568              assert resp1.status == 202
569  
570              resp2 = await cli.post(
571                  "/webhooks/ci",
572                  json={"ref": "dev"},
573                  headers={"X-GitHub-Delivery": "bbb-222"},
574              )
575              assert resp2.status == 202
576  
577          # Wait for the async tasks to be created
578          await asyncio.sleep(0.05)
579  
580          assert len(captured_events) == 2
581          ids = {ev.source.chat_id for ev in captured_events}
582          assert len(ids) == 2, "Each delivery must have a unique session chat_id"
583  
584  
585  # ===================================================================
586  # Delivery info cleanup
587  # ===================================================================
588  
589  
590  class TestDeliveryCleanup:
591  
592      @pytest.mark.asyncio
593      async def test_delivery_info_survives_multiple_sends(self):
594          """send() must NOT pop delivery_info.
595  
596          Interim status messages (fallback notifications, context-pressure
597          warnings, etc.) flow through the same send() path as the final
598          response.  If the entry were popped on the first send, the final
599          response would silently downgrade to the ``log`` deliver type.
600          Regression test for that bug.
601          """
602          adapter = _make_adapter()
603          chat_id = "webhook:test:d-xyz"
604          adapter._delivery_info[chat_id] = {
605              "deliver": "log",
606              "deliver_extra": {},
607              "payload": {"x": 1},
608          }
609          adapter._delivery_info_created[chat_id] = time.time()
610  
611          # First send (e.g. an interim status message)
612          result1 = await adapter.send(chat_id, "Status: switching to fallback")
613          assert result1.success is True
614          # Entry must still be present so the final send can read it
615          assert chat_id in adapter._delivery_info
616  
617          # Second send (the final agent response)
618          result2 = await adapter.send(chat_id, "Final agent response")
619          assert result2.success is True
620          assert chat_id in adapter._delivery_info
621  
622      @pytest.mark.asyncio
623      async def test_delivery_info_pruned_via_ttl(self):
624          """Stale delivery_info entries are dropped on the next POST."""
625          adapter = _make_adapter()
626          adapter._idempotency_ttl = 60  # short TTL for the test
627          now = time.time()
628  
629          # Stale entry — older than TTL
630          adapter._delivery_info["webhook:test:old"] = {"deliver": "log"}
631          adapter._delivery_info_created["webhook:test:old"] = now - 120
632  
633          # Fresh entry — should survive
634          adapter._delivery_info["webhook:test:new"] = {"deliver": "log"}
635          adapter._delivery_info_created["webhook:test:new"] = now - 5
636  
637          adapter._prune_delivery_info(now)
638  
639          assert "webhook:test:old" not in adapter._delivery_info
640          assert "webhook:test:old" not in adapter._delivery_info_created
641          assert "webhook:test:new" in adapter._delivery_info
642          assert "webhook:test:new" in adapter._delivery_info_created
643  
644  
645  # ===================================================================
646  # check_webhook_requirements
647  # ===================================================================
648  
649  
650  class TestCheckRequirements:
651      def test_returns_true_when_aiohttp_available(self):
652          assert check_webhook_requirements() is True
653  
654      @patch("gateway.platforms.webhook.AIOHTTP_AVAILABLE", False)
655      def test_returns_false_without_aiohttp(self):
656          assert check_webhook_requirements() is False
657  
658  
659  # ===================================================================
660  # __raw__ template token
661  # ===================================================================
662  
663  
664  class TestRawTemplateToken:
665      """Tests for the {__raw__} special token in _render_prompt."""
666  
667      def test_raw_resolves_to_full_json_payload(self):
668          """{__raw__} in a template dumps the entire payload as JSON."""
669          adapter = _make_adapter()
670          payload = {"action": "opened", "number": 42}
671          result = adapter._render_prompt(
672              "Payload: {__raw__}", payload, "push", "test"
673          )
674          expected_json = json.dumps(payload, indent=2)
675          assert result == f"Payload: {expected_json}"
676  
677      def test_raw_truncated_at_4000_chars(self):
678          """{__raw__} output is truncated at 4000 characters for large payloads."""
679          adapter = _make_adapter()
680          # Build a payload whose JSON repr exceeds 4000 chars
681          payload = {"data": "x" * 5000}
682          result = adapter._render_prompt("{__raw__}", payload, "push", "test")
683          assert len(result) <= 4000
684  
685      def test_raw_mixed_with_other_variables(self):
686          """{__raw__} can be mixed with regular template variables."""
687          adapter = _make_adapter()
688          payload = {"action": "closed", "number": 7}
689          result = adapter._render_prompt(
690              "Action={action} Raw={__raw__}", payload, "push", "test"
691          )
692          assert result.startswith("Action=closed Raw=")
693          assert '"action": "closed"' in result
694          assert '"number": 7' in result
695  
696  
697  # ===================================================================
698  # Cross-platform delivery thread_id passthrough
699  # ===================================================================
700  
701  
702  class TestDeliverCrossPlatformThreadId:
703      """Tests for thread_id passthrough in _deliver_cross_platform."""
704  
705      def _setup_adapter_with_mock_target(self):
706          """Set up a webhook adapter with a mocked gateway_runner and target adapter."""
707          adapter = _make_adapter()
708          mock_target = AsyncMock()
709          mock_target.send = AsyncMock(return_value=SendResult(success=True))
710  
711          mock_runner = MagicMock()
712          mock_runner.adapters = {Platform("telegram"): mock_target}
713          mock_runner.config.get_home_channel.return_value = None
714  
715          adapter.gateway_runner = mock_runner
716          return adapter, mock_target
717  
718      @pytest.mark.asyncio
719      async def test_thread_id_passed_as_metadata(self):
720          """thread_id from deliver_extra is passed as metadata to adapter.send()."""
721          adapter, mock_target = self._setup_adapter_with_mock_target()
722          delivery = {
723              "deliver_extra": {
724                  "chat_id": "12345",
725                  "thread_id": "999",
726              }
727          }
728          await adapter._deliver_cross_platform("telegram", "hello", delivery)
729          mock_target.send.assert_awaited_once_with(
730              "12345", "hello", metadata={"thread_id": "999"}
731          )
732  
733      @pytest.mark.asyncio
734      async def test_message_thread_id_passed_as_thread_id(self):
735          """message_thread_id from deliver_extra is mapped to thread_id in metadata."""
736          adapter, mock_target = self._setup_adapter_with_mock_target()
737          delivery = {
738              "deliver_extra": {
739                  "chat_id": "12345",
740                  "message_thread_id": "888",
741              }
742          }
743          await adapter._deliver_cross_platform("telegram", "hello", delivery)
744          mock_target.send.assert_awaited_once_with(
745              "12345", "hello", metadata={"thread_id": "888"}
746          )
747  
748      @pytest.mark.asyncio
749      async def test_no_thread_id_sends_no_metadata(self):
750          """When no thread_id is present, metadata is None."""
751          adapter, mock_target = self._setup_adapter_with_mock_target()
752          delivery = {
753              "deliver_extra": {
754                  "chat_id": "12345",
755              }
756          }
757          await adapter._deliver_cross_platform("telegram", "hello", delivery)
758          mock_target.send.assert_awaited_once_with(
759              "12345", "hello", metadata=None
760          )