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.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user