WhatsApp demo path (relay #748): - WHATSAPP_DEMO_TO config under the WhatsApp section (env-based, .env-only; .env.example keeps an empty placeholder; real numbers never enter source). - build_demo_webhook_payload() in app/routers/whatsapp.py builds the Meta demo payload from it (fails closed when unset), so the webhook round trip logs from_number = demo number (surfaces in GET /api/whatsapp/mock-log) and the auto-reply targets the same number. - tests/test_whatsapp_demo_number.py: default empty + never committed in tracked files, payload builder from/to, 200/403/401 gates unchanged. Branding (logo-assets-v1, sha256-verified, same-origin app/static/branding): - Login header uses h96 full lockup; logged-in topbar (base.html) uses h48 on a light chip (logo ink is ~2:1 vs the dark nav); favicons 32x32 + 16x16 in <head>. img-src 'self' data: blob: already allows /static/branding/*. - tests/test_branding_assets.py: page placement + same-origin serving + CSP. - AGENTS.md synced.
165 lines
6.9 KiB
Python
165 lines
6.9 KiB
Python
"""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
|