feat(whatsapp,branding): demo WhatsApp number env wiring + Denya logo assets #15
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|--------|------|------|-------------|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
After Width: | Height: | Size: 793 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 118 KiB |
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||