feat(whatsapp,branding): demo WhatsApp number env wiring + Denya logo assets #15

Merged
abiba-bot merged 1 commits from fm/denya-nextwave-20260909 into main 2026-09-09 19:47:38 +00:00
15 changed files with 330 additions and 13 deletions
+7
View File
@@ -20,6 +20,13 @@ META_GRAPH_BASE=https://graph.facebook.com/v18.0
# Generate with: openssl rand -hex 32
WHATSAPP_WEBHOOK_SECRET=
# ── WhatsApp demo path (optional) ───────────────────────
# Expected sender/recipient number (E.164) for the WhatsApp demo round trip
# (webhook -> ticket -> mock-log). Set the real number ONLY on the deploy
# host's .env — keep this template an empty placeholder, never a live number.
# Empty (default): the demo payload builder fails closed (no fabricated sender).
WHATSAPP_DEMO_TO=
# ── Login rate limiting (P0) ────────────────────────────
# ~5 failed login attempts per 15 minutes per IP+email → HTTP 429
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
+20
View File
@@ -73,6 +73,15 @@ read/write 500s. `ensure_legacy_schema` (app/main.py) adds the missing columns
and backfills+drops the obsolete NOT NULL `command` column idempotently at
startup — do not hand-edit legacy DBs, ship a self-heal there instead.
Demo WhatsApp round trip: `WHATSAPP_DEMO_TO` (E.164, .env-only — never commit
a real number; `.env.example` keeps an empty placeholder) is the expected
sender/recipient for the demo path.
`app/routers/whatsapp.py::build_demo_webhook_payload` builds a Meta webhook
payload from it (fails closed when unset), so posting it to
`POST /api/whatsapp/webhook` with the secret logs `from_number` = demo number
(visible via `GET /api/whatsapp/mock-log`) and the auto-reply targets the same
number. Covered by `tests/test_whatsapp_demo_number.py`.
### Pages (Sprint 3) — Jinja2 templates at `app/templates/`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -108,6 +117,17 @@ Frontend: Alpine.js + Tailwind CSS vendored same-origin (no CDN) — see
`test_csp_script_src_allows_unsafe_eval_for_alpine` in
`tests/test_frontend_vendoring.py`.
### Branding (logo)
Official Denya Developers logo derivatives are committed under
`app/static/branding/` (Gitea release `logo-assets-v1`, sha256-verified
monochrome forest-green lockup; sourced from the Gitea release, never from
kagentz). Placement: login header uses `denya-logo-h96.png` (full lockup); the
logged-in topbar (base.html nav) uses `denya-logo-h48.png` on a light chip
(logo ink is only ~2:1 against the dark `#0d2b18` nav — keep a light chip
there); favicons 32x32+16x16 declared in `base.html <head>`.
`img-src 'self' data: blob:` already covers `/static/branding/*` — no CSP
change. Regression coverage: `tests/test_branding_assets.py`.
### Tickets (Sprint 2)
| Method | Path | Auth | Description |
|--------|------|------|-------------|
+8
View File
@@ -39,6 +39,14 @@ class Settings(BaseSettings):
# Shared secret for inbound webhook POSTs (header ``X-Webhook-Secret``).
# Fail-closed: when unset/empty the webhook rejects every message.
WHATSAPP_WEBHOOK_SECRET: str = ""
# Expected sender/recipient number (E.164) for the WhatsApp demo round
# trip (webhook -> ticket -> mock-log). Set per deployment in .env only —
# never commit a real number. When set, the demo payload builder
# (``app/routers/whatsapp.py::build_demo_webhook_payload``) originates
# messages from it, the auto-reply targets it, and ``/api/whatsapp/mock-log``
# surfaces it. Empty (default) means the demo payload cannot be built:
# the helper fails closed rather than fabricating a sender.
WHATSAPP_DEMO_TO: str = ""
# ── Login rate limiting ──────────────────────────────────────────
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: int = 5
+41
View File
@@ -46,6 +46,47 @@ REPLY_TEMPLATE = (
)
# ── WhatsApp demo round trip (WHATSAPP_DEMO_TO) ─────────────────────
def build_demo_webhook_payload(
text: str = "Demo message — Denya OneCare WhatsApp round trip",
wa_message_id: str = "wamid.demo.000001",
) -> dict:
"""Build a Meta webhook payload for the WhatsApp demo round trip.
The message ``from`` is ``settings.WHATSAPP_DEMO_TO`` (E.164), so the demo
surfaces the expected number end-to-end: the webhook logs it in
``whatsapp_log`` (visible via ``GET /api/whatsapp/mock-log``) and the
auto-reply is sent back to the same number. The demo number is configured
per deployment in .env (never committed); when it is unset this raises
rather than fabricating a sender (same fail-closed posture as the webhook
secret).
"""
demo_to = (settings.WHATSAPP_DEMO_TO or "").strip()
if not demo_to:
raise RuntimeError(
"WHATSAPP_DEMO_TO is not configured — set the demo sender number "
"in .env to run the WhatsApp demo round trip."
)
return {
"object": "whatsapp_business_account",
"entry": [
{
"id": "1",
"changes": [
{
"id": wa_message_id,
"message": {
"from": demo_to,
"id": wa_message_id,
"text": {"text": text},
},
}
],
}
],
}
# ── Meta Graph API helpers ──────────────────────────────────────────
async def send_whatsapp_reply(
to_phone: str,
Binary file not shown.

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+7 -8
View File
@@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Denya OneCare</title>
<!-- Favicons (same-origin app/static/branding) -->
<link rel="icon" type="image/png" sizes="32x32" href="/static/branding/denya-logo-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/branding/denya-logo-16x16.png">
<!-- Vendored same-origin (no CDN): app/static/vendor/ — LAN-safe demo -->
<script src="/static/vendor/alpine-3.17.2.min.js" defer></script>
<script src="/static/vendor/tailwind-3.4.17.js"></script>
@@ -68,15 +71,11 @@
<!-- Left side -->
<div class="flex items-center space-x-4">
<a href="/dashboard/cs" class="flex items-center space-x-3">
<!-- Denya Developers Logo Mark -->
<div class="w-9 h-9 bg-gold rounded-lg flex items-center justify-center shadow-sm">
<svg class="w-5 h-5 text-[#0d2b18]" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
</div>
<!-- Denya Developers logo (same-origin app/static/branding) -->
<img src="/static/branding/denya-logo-h48.png" alt="Denya Developers"
class="h-9 w-auto bg-white rounded-md px-1.5 py-1 shadow-sm">
<div class="flex flex-col">
<span class="text-white font-bold text-base leading-tight">Denya Developers</span>
<span class="text-gold text-xs leading-tight font-medium">OneCare</span>
<span class="text-gold text-sm leading-tight font-bold">OneCare</span>
</div>
</a>
<!-- Nav Links -->
+3 -5
View File
@@ -4,11 +4,9 @@
<div class="w-full max-w-md" x-data="loginForm()">
<div class="bg-white rounded-2xl shadow-lg p-8">
<div class="text-center mb-8">
<div class="mx-auto w-16 h-16 bg-denya-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-denya-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
<!-- Denya Developers full lockup (same-origin app/static/branding) -->
<img src="/static/branding/denya-logo-h96.png" alt="Denya Developers"
class="mx-auto mb-4 h-24 w-auto">
<h1 class="text-2xl font-bold text-gray-900">Denya OneCare</h1>
<p class="text-gray-500 mt-1">Sign in to your dashboard</p>
</div>
+80
View File
@@ -0,0 +1,80 @@
"""Denya logo branding — repo-local assets under app/static/branding/.
The official Denya Developers logo derivatives (Gitea release
``logo-assets-v1``, sha256-verified) are committed same-origin under
``app/static/branding/`` like the vendored frontend libraries — no CDN, no CSP
change needed (``img-src 'self' data: blob:`` already covers them).
Placement contract:
* Login page header uses the h96 full lockup (``denya-logo-h96.png``).
* Dashboard topbar (base.html nav) uses the h48 full lockup (compact spot:
the DEVELOPERS subtext is unreadable below ~48px, so the mark reads as
symbol+DENYA — the intended compact treatment).
* Favicon: 32x32 declared first, 16x16 declared, both in <head>.
"""
from __future__ import annotations
import re
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
BRANDING_ASSETS = (
"denya-logo.png",
"denya-logo-trimmed.png",
"denya-logo-h48.png",
"denya-logo-h96.png",
"denya-logo-64x64.png",
"denya-logo-32x32.png",
"denya-logo-16x16.png",
)
def _directive_sources(csp: str, directive: str) -> list[str]:
"""Return the source list of one CSP directive (e.g. ``img-src``)."""
for part in csp.split(";"):
tokens = part.split()
if tokens and tokens[0].strip() == directive:
return [t.strip() for t in tokens[1:]]
return []
async def test_login_page_header_uses_h96_logo(client: AsyncClient):
"""/login must carry the h96 full-lockup logo (same-origin URL)."""
resp = await client.get("/login")
assert resp.status_code == 200, resp.text
assert "/static/branding/denya-logo-h96.png" in resp.text
async def test_topbar_and_favicon_on_dashboard_pages(client: AsyncClient):
"""Dashboard chrome (base.html) carries h48 topbar logo + both favicons."""
resp = await client.get("/dashboard/fm")
assert resp.status_code == 200, resp.text
assert "/static/branding/denya-logo-h48.png" in resp.text
# Favicon 32x32 with 16x16 declared in <head> (link rel="icon").
assert 'rel="icon" type="image/png" sizes="32x32" href="/static/branding/denya-logo-32x32.png"' in resp.text
assert 'rel="icon" type="image/png" sizes="16x16" href="/static/branding/denya-logo-16x16.png"' in resp.text
async def test_branding_assets_served_same_origin(client: AsyncClient):
"""Every committed branding asset must resolve locally as a PNG."""
for name in BRANDING_ASSETS:
url = f"/static/branding/{name}"
resp = await client.get(url)
assert resp.status_code == 200, f"{url} -> {resp.status_code}"
assert resp.headers.get("content-type", "").startswith("image/png"), f"{url}: {resp.headers.get('content-type')!r}"
assert len(resp.content) > 100, f"{url} looks empty"
async def test_csp_serves_branding_without_changes(client: AsyncClient):
"""img-src already allows same-origin PNGs — no external host needed."""
resp = await client.get("/login")
assert resp.status_code == 200
csp = resp.headers["content-security-policy"]
img_sources = _directive_sources(csp, "img-src")
assert img_sources, f"no img-src directive in CSP: {csp}"
assert "'self'" in img_sources and "data:" in img_sources and "blob:" in img_sources
assert "cdn." not in csp # fully self-contained, like the vendored scripts
+164
View File
@@ -0,0 +1,164 @@
"""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