/ tests / test_whatsapp_webhook.py
test_whatsapp_webhook.py
  1  """WhatsApp Business Cloud API webhook tests.
  2  
  3  Covers:
  4  * GET subscription handshake — happy path + bad verify_token → 403.
  5  * POST signature verification — bad sig → 401.
  6  * POST routing by phone_number_id — unknown id → 200 ack, no agent run.
  7  * Allowlist gate — sender outside allowlist → 200 ack, "not authorized"
  8    reply queued.
  9  * Non-text message → 200 ack, polite "text only" reply queued.
 10  
 11  The agent dispatch is patched out in every test that exercises a
 12  project-bound message because we don't want to spin up a real LLM, and
 13  because TestClient's BackgroundTasks runs synchronously after the
 14  response — letting the real chat pipeline run would either blow up on
 15  missing LLM config or take seconds.
 16  """
 17  from __future__ import annotations
 18  
 19  import hashlib
 20  import hmac
 21  import json
 22  
 23  import pytest
 24  from fastapi.testclient import TestClient
 25  
 26  from restai.config import RESTAI_DEFAULT_PASSWORD
 27  from restai.database import get_db_wrapper
 28  from restai.main import app
 29  from restai.utils.crypto import encrypt_field
 30  
 31  
 32  AUTH = ("admin", RESTAI_DEFAULT_PASSWORD)
 33  
 34  # Constants used in the fake project we set up. These are intentionally
 35  # distinctive so a stale row from a prior run is easy to spot in the DB.
 36  PHONE_NUMBER_ID = "wa_test_phone_999000111"
 37  ACCESS_TOKEN = "wa_test_access_token_xyz"
 38  APP_SECRET = "wa_test_app_secret_super_random"
 39  VERIFY_TOKEN = "wa_test_verify_token_abc"
 40  ALLOWED_SENDER = "351900000001"
 41  BLOCKED_SENDER = "999999999999"
 42  
 43  
 44  @pytest.fixture(scope="module")
 45  def client():
 46      with TestClient(app) as c:
 47          yield c
 48  
 49  
 50  @pytest.fixture(scope="module")
 51  def project_id(client):
 52      """Create (or find) an agent project, then write WhatsApp options
 53      directly into the DB so we don't depend on the project create API
 54      accepting our new keys (it does, but bypassing keeps the test
 55      hermetic to model-validator changes)."""
 56      teams = client.get("/teams", auth=AUTH).json().get("teams", []) or []
 57      if not teams:
 58          pytest.skip("no team available to bootstrap project")
 59      team_id = teams[0]["id"]
 60  
 61      name = "whatsapp_webhook_test_project"
 62      # Find existing by name via the list endpoint (per-id GET requires int).
 63      listing = client.get("/projects", auth=AUTH).json().get("projects", []) or []
 64      existing = next((p for p in listing if p.get("name") == name), None)
 65      if existing:
 66          pid = existing["id"]
 67      else:
 68          # Pick whatever LLM is registered. In CI / fresh dev DB the
 69          # default seed includes one; if not, skip cleanly.
 70          info = client.get("/info", auth=AUTH).json()
 71          llms = info.get("llms") or []
 72          llm_name = llms[0].get("name") if llms else None
 73          if not llm_name:
 74              pytest.skip("no LLMs configured — cannot bootstrap project")
 75          resp = client.post(
 76              "/projects",
 77              json={"name": name, "type": "agent", "llm": llm_name, "team_id": team_id},
 78              auth=AUTH,
 79          )
 80          if resp.status_code not in (200, 201):
 81              pytest.skip(f"could not create test project: {resp.status_code} {resp.text}")
 82          pid = resp.json()["id"]
 83  
 84      # Write WhatsApp options directly: secrets must be encrypted at rest
 85      # because the webhook handler decrypts them on the way out.
 86      db = get_db_wrapper()
 87      try:
 88          from restai.models.databasemodels import ProjectDatabase
 89          proj = db.db.query(ProjectDatabase).filter(ProjectDatabase.id == pid).first()
 90          opts = json.loads(proj.options or "{}")
 91          opts.update({
 92              "whatsapp_phone_number_id": PHONE_NUMBER_ID,
 93              "whatsapp_access_token": encrypt_field(ACCESS_TOKEN),
 94              "whatsapp_app_secret": encrypt_field(APP_SECRET),
 95              "whatsapp_verify_token": encrypt_field(VERIFY_TOKEN),
 96              "whatsapp_allowed_phone_numbers": ALLOWED_SENDER,
 97          })
 98          proj.options = json.dumps(opts)
 99          db.db.commit()
100      finally:
101          db.db.close()
102  
103      return pid
104  
105  
106  def _sign(body: bytes, secret: str = APP_SECRET) -> str:
107      return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
108  
109  
110  def _make_payload(from_phone: str = ALLOWED_SENDER, msg_type: str = "text",
111                     text: str = "hello bot") -> bytes:
112      msg = {"from": from_phone, "id": "wamid.test", "type": msg_type}
113      if msg_type == "text":
114          msg["text"] = {"body": text}
115      return json.dumps({
116          "object": "whatsapp_business_account",
117          "entry": [{
118              "id": "biz_account_id",
119              "changes": [{
120                  "field": "messages",
121                  "value": {
122                      "messaging_product": "whatsapp",
123                      "metadata": {"phone_number_id": PHONE_NUMBER_ID},
124                      "messages": [msg],
125                  },
126              }],
127          }],
128      }).encode()
129  
130  
131  # ─── GET handshake ──────────────────────────────────────────────────────
132  
133  def test_verify_handshake_ok(client, project_id):
134      r = client.get("/webhooks/whatsapp", params={
135          "hub.mode": "subscribe",
136          "hub.challenge": "challenge_value_42",
137          "hub.verify_token": VERIFY_TOKEN,
138      })
139      assert r.status_code == 200, r.text
140      assert r.text == "challenge_value_42"
141  
142  
143  def test_verify_handshake_bad_token(client, project_id):
144      r = client.get("/webhooks/whatsapp", params={
145          "hub.mode": "subscribe",
146          "hub.challenge": "challenge_value_42",
147          "hub.verify_token": "definitely_wrong_token",
148      })
149      assert r.status_code == 403
150  
151  
152  def test_verify_handshake_missing_params(client):
153      r = client.get("/webhooks/whatsapp", params={"hub.mode": "subscribe"})
154      assert r.status_code == 400
155  
156  
157  # ─── POST routing & signature ───────────────────────────────────────────
158  
159  def test_post_unknown_phone_number_id_acks_silently(client):
160      """If no project owns the phone_number_id, signature is never checked
161      (we don't have a secret to check it with). Still ack 200 so Meta
162      doesn't retry — it's an inbound for a project that was deleted."""
163      body = json.dumps({
164          "object": "whatsapp_business_account",
165          "entry": [{"changes": [{"value": {
166              "messaging_product": "whatsapp",
167              "metadata": {"phone_number_id": "phone_id_we_do_not_know"},
168              "messages": [{"from": ALLOWED_SENDER, "type": "text", "text": {"body": "hi"}}],
169          }}]}],
170      }).encode()
171      r = client.post(
172          "/webhooks/whatsapp",
173          content=body,
174          headers={"X-Hub-Signature-256": "sha256=00", "content-type": "application/json"},
175      )
176      assert r.status_code == 200
177      assert r.json() == {"status": "ok"}
178  
179  
180  def test_post_bad_signature_returns_401(client, project_id, monkeypatch):
181      body = _make_payload()
182      sent_messages = []
183      monkeypatch.setattr(
184          "restai.routers.whatsapp_webhook.send_message",
185          lambda *a, **kw: sent_messages.append(a) or {"ok": True},
186      )
187  
188      r = client.post(
189          "/webhooks/whatsapp",
190          content=body,
191          headers={"X-Hub-Signature-256": "sha256=tampered_sig", "content-type": "application/json"},
192      )
193      assert r.status_code == 401
194      assert sent_messages == [], "no outbound on bad sig"
195  
196  
197  def test_post_text_message_dispatches_agent(client, project_id, monkeypatch):
198      """Valid signature + allowlisted sender + text → agent runs and
199      reply is sent. We mock both the agent dispatch and the outbound
200      send so the test stays hermetic."""
201      sent_messages = []
202      monkeypatch.setattr(
203          "restai.routers.whatsapp_webhook.send_message",
204          lambda token, pid, to, text: sent_messages.append((token, pid, to, text)) or {"ok": True},
205      )
206  
207      async def fake_run_agent(project_id, text, from_phone):
208          return f"echo: {text}"
209      monkeypatch.setattr(
210          "restai.routers.whatsapp_webhook._run_agent",
211          fake_run_agent,
212      )
213  
214      body = _make_payload(text="hello bot")
215      sig = _sign(body)
216      r = client.post(
217          "/webhooks/whatsapp",
218          content=body,
219          headers={"X-Hub-Signature-256": sig, "content-type": "application/json"},
220      )
221      assert r.status_code == 200
222      assert sent_messages, "agent reply should have been queued"
223      token, pid, to, reply = sent_messages[-1]
224      assert token == ACCESS_TOKEN
225      assert pid == PHONE_NUMBER_ID
226      assert to == ALLOWED_SENDER
227      assert reply == "echo: hello bot"
228  
229  
230  def test_post_blocked_sender_replies_unauthorized(client, project_id, monkeypatch):
231      sent_messages = []
232      monkeypatch.setattr(
233          "restai.routers.whatsapp_webhook.send_message",
234          lambda token, pid, to, text: sent_messages.append((to, text)) or {"ok": True},
235      )
236      agent_called = []
237      async def fake_run_agent(project_id, text, from_phone):
238          agent_called.append(from_phone)
239          return "should not happen"
240      monkeypatch.setattr("restai.routers.whatsapp_webhook._run_agent", fake_run_agent)
241  
242      body = _make_payload(from_phone=BLOCKED_SENDER, text="hello")
243      sig = _sign(body)
244      r = client.post(
245          "/webhooks/whatsapp",
246          content=body,
247          headers={"X-Hub-Signature-256": sig, "content-type": "application/json"},
248      )
249      assert r.status_code == 200
250      assert agent_called == [], "agent must not run for blocked senders"
251      assert sent_messages, "polite reject should be queued"
252      to, reply = sent_messages[-1]
253      assert to == BLOCKED_SENDER
254      assert "not authorized" in reply.lower()
255  
256  
257  def test_post_non_text_message_replies_text_only_notice(client, project_id, monkeypatch):
258      sent_messages = []
259      monkeypatch.setattr(
260          "restai.routers.whatsapp_webhook.send_message",
261          lambda token, pid, to, text: sent_messages.append((to, text)) or {"ok": True},
262      )
263      agent_called = []
264      async def fake_run_agent(project_id, text, from_phone):
265          agent_called.append(from_phone)
266          return "should not happen"
267      monkeypatch.setattr("restai.routers.whatsapp_webhook._run_agent", fake_run_agent)
268  
269      body = _make_payload(msg_type="image")
270      sig = _sign(body)
271      r = client.post(
272          "/webhooks/whatsapp",
273          content=body,
274          headers={"X-Hub-Signature-256": sig, "content-type": "application/json"},
275      )
276      assert r.status_code == 200
277      assert agent_called == [], "agent must not run for non-text messages"
278      assert sent_messages, "text-only notice should be queued"
279      to, reply = sent_messages[-1]
280      assert to == ALLOWED_SENDER
281      assert "text" in reply.lower()
282  
283  
284  # ─── verify_signature unit tests (no network) ──────────────────────────
285  
286  def test_verify_signature_rejects_missing_header():
287      from restai.whatsapp import verify_signature
288      assert verify_signature(b"body", None, "secret") is False
289      assert verify_signature(b"body", "", "secret") is False
290  
291  
292  def test_verify_signature_rejects_bad_prefix():
293      from restai.whatsapp import verify_signature
294      assert verify_signature(b"body", "md5=abcd", "secret") is False
295  
296  
297  def test_verify_signature_accepts_valid_hmac():
298      from restai.whatsapp import verify_signature
299      body = b'{"hello":"world"}'
300      sig = "sha256=" + hmac.new(b"secret", body, hashlib.sha256).hexdigest()
301      assert verify_signature(body, sig, "secret") is True
302  
303  
304  def test_verify_signature_constant_time_mismatch():
305      from restai.whatsapp import verify_signature
306      body = b'{"hello":"world"}'
307      sig = "sha256=" + hmac.new(b"different_key", body, hashlib.sha256).hexdigest()
308      assert verify_signature(body, sig, "secret") is False