Files
denya-onecare/tests/test_whatsapp_demo_number.py
root a6eb799efa feat(whatsapp,branding): WhatsApp demo-number wiring + Denya logo assets
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.
2026-09-09 19:32:21 +00:00

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