"""WhatsApp demo-number wiring (WHATSAPP_DEMO_TO). The demo round trip (webhook POST -> ticket -> auto-reply -> mock-log) surfaces the expected sender/recipient number. That number is **never committed**: it lives only in the deploy host's .env and reaches the app through the ``WHATSAPP_DEMO_TO`` setting (same fail-closed env pattern as the webhook secret). ``build_demo_webhook_payload()`` builds the demo payload from the setting so mock-log and the auto-reply show the configured number; with the setting unset it raises instead of fabricating a sender. Anchors: * ``settings.WHATSAPP_DEMO_TO`` defaults to ``""`` and no tracked file assigns it a value (real numbers stay out of git history). * ``build_demo_webhook_payload`` uses the configured number as the message ``from`` and fails closed when unset. * A full round trip with the secret + demo number logs the number and surfaces it in ``/api/whatsapp/mock-log``; the auto-reply targets the same number. * Webhook stays 403 without the secret and mock-log stays 401 without auth. """ from __future__ import annotations import re import subprocess from pathlib import Path import pytest pytestmark = pytest.mark.asyncio from app.core.config import settings # noqa: E402 # Clearly-fake test number — never use a real contact number in source. _FAKE_DEMO_TO = "+233559999999" async def _login(client, email="wahab@denya.com", password="denya123") -> str: resp = await client.post( "/api/auth/login", json={"email": email, "password": password}, ) assert resp.status_code == 200, resp.text return resp.json()["access_token"] def _auth(token: str) -> dict: return {"Authorization": f"Bearer {token}"} async def _capture_reply(monkeypatch, calls: list): """Swap the Meta client for a recorder; returns the fake.""" from app.routers import whatsapp as whatsapp_router from app.schemas.whatsapp import WhatsAppReplyResponse async def _fake_reply(to_phone: str, text: str): calls.append((to_phone, text)) return WhatsAppReplyResponse(success=True, message="sent") monkeypatch.setattr(whatsapp_router, "send_whatsapp_reply", _fake_reply) return whatsapp_router # ── Config plumbing ─────────────────────────────────────────────────── def test_demo_number_defaults_empty_and_never_committed(): """WHATSAPP_DEMO_TO must default empty; no tracked file may set a value. Real WhatsApp numbers are deploy-host .env secrets — a committed value (even in tests or .env.example) would leak into git history. """ assert settings.WHATSAPP_DEMO_TO == "" root = Path.cwd() listed = subprocess.run( ["git", "ls-files", "-z"], cwd=root, capture_output=True, text=True ) assert listed.returncode == 0, "git ls-files failed inside the test repo" tracked = [p for p in listed.stdout.split("\0") if p] assignment = re.compile(r"^WHATSAPP_DEMO_TO[ \t]*=[ \t]*(\S*)$") offenders = [] for rel in tracked: path = root / rel if path.suffix.lower() in {".png", ".jpg", ".jpeg", ".db", ".pyc", ".ico", ".woff", ".woff2", ".gz"}: continue # binaries cannot carry a text assignment try: text = path.read_text(encoding="utf-8", errors="ignore") except OSError: continue for lineno, line in enumerate(text.splitlines(), start=1): match = assignment.match(line) if match and match.group(1): offenders.append(f"{rel}:{lineno}: WHATSAPP_DEMO_TO={match.group(1)!r}") assert not offenders, ( "WHATSAPP_DEMO_TO must stay unset in tracked files (set it in the " f"deploy host .env only); found: {offenders}" ) # ── Demo payload builder ───────────────────────────────────────────── def test_demo_payload_builder_fails_closed_when_unset(monkeypatch): """Without WHATSAPP_DEMO_TO the builder raises rather than fabricating.""" from app.routers.whatsapp import build_demo_webhook_payload monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", "") with pytest.raises(RuntimeError, match="WHATSAPP_DEMO_TO"): build_demo_webhook_payload() def test_demo_payload_builder_uses_configured_number(monkeypatch): """The demo payload's message ``from`` is the configured demo number.""" from app.routers.whatsapp import build_demo_webhook_payload monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO) payload = build_demo_webhook_payload(text="Leaking tap", wa_message_id="wamid.demo.42") entry = payload["entry"][0]["changes"][0] assert entry["message"]["from"] == _FAKE_DEMO_TO assert entry["message"]["id"] == "wamid.demo.42" assert entry["message"]["text"]["text"] == "Leaking tap" # ── Demo round trip ────────────────────────────────────────────────── async def test_demo_round_trip_surfaces_number_in_mock_log(client, monkeypatch): """Webhook demo payload -> ticket + log; mock-log shows the demo number.""" from app.routers.whatsapp import build_demo_webhook_payload replies: list[tuple[str, str]] = [] await _capture_reply(monkeypatch, replies) monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret") monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO) resp = await client.post( "/api/whatsapp/webhook", json=build_demo_webhook_payload(text="Demo leak"), headers={"X-Webhook-Secret": "test-webhook-secret"}, ) assert resp.status_code == 200, resp.text data = resp.json() assert data["status"] == "processed" assert data["ticket_number"].startswith("PAV-") # Auto-reply went back to the demo number. assert replies and replies[0][0] == _FAKE_DEMO_TO, replies token = await _login(client) log = await client.get("/api/whatsapp/mock-log", headers=_auth(token)) assert log.status_code == 200, log.text entries = log.json() assert any(e["from_number"] == _FAKE_DEMO_TO for e in entries) assert any(e["ticket_number"] == data["ticket_number"] for e in entries) async def test_webhook_still_403_without_secret_even_with_demo_number(client, monkeypatch): """The webhook secret gate is independent of the demo number.""" monkeypatch.setattr(settings, "WHATSAPP_WEBHOOK_SECRET", "test-webhook-secret") monkeypatch.setattr(settings, "WHATSAPP_DEMO_TO", _FAKE_DEMO_TO) resp = await client.post("/api/whatsapp/webhook", json={"object": "whatsapp_business_account"}) assert resp.status_code == 403 async def test_mock_log_still_401_gated(client): """mock-log stays authenticated-only (no token -> 401).""" resp = await client.get("/api/whatsapp/mock-log") assert resp.status_code == 401, resp.text