diff --git a/.env.example b/.env.example index 56ff850..b514cde 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 08dac52..270f375 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `. +`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 | |--------|------|------|-------------| diff --git a/app/core/config.py b/app/core/config.py index 75b530d..bceb4c3 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 diff --git a/app/routers/whatsapp.py b/app/routers/whatsapp.py index b24d422..29d58ec 100644 --- a/app/routers/whatsapp.py +++ b/app/routers/whatsapp.py @@ -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, diff --git a/app/static/branding/denya-logo-16x16.png b/app/static/branding/denya-logo-16x16.png new file mode 100644 index 0000000..44a4f64 Binary files /dev/null and b/app/static/branding/denya-logo-16x16.png differ diff --git a/app/static/branding/denya-logo-32x32.png b/app/static/branding/denya-logo-32x32.png new file mode 100644 index 0000000..251385c Binary files /dev/null and b/app/static/branding/denya-logo-32x32.png differ diff --git a/app/static/branding/denya-logo-64x64.png b/app/static/branding/denya-logo-64x64.png new file mode 100644 index 0000000..1ea60e9 Binary files /dev/null and b/app/static/branding/denya-logo-64x64.png differ diff --git a/app/static/branding/denya-logo-h48.png b/app/static/branding/denya-logo-h48.png new file mode 100644 index 0000000..b5526ca Binary files /dev/null and b/app/static/branding/denya-logo-h48.png differ diff --git a/app/static/branding/denya-logo-h96.png b/app/static/branding/denya-logo-h96.png new file mode 100644 index 0000000..dd4b544 Binary files /dev/null and b/app/static/branding/denya-logo-h96.png differ diff --git a/app/static/branding/denya-logo-trimmed.png b/app/static/branding/denya-logo-trimmed.png new file mode 100644 index 0000000..680deec Binary files /dev/null and b/app/static/branding/denya-logo-trimmed.png differ diff --git a/app/static/branding/denya-logo.png b/app/static/branding/denya-logo.png new file mode 100644 index 0000000..d4aacfd Binary files /dev/null and b/app/static/branding/denya-logo.png differ diff --git a/app/templates/base.html b/app/templates/base.html index 76e131a..7feac9c 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,6 +4,9 @@ Denya OneCare + + + @@ -68,15 +71,11 @@
- -
- - - -
+ + Denya Developers
- Denya Developers - OneCare + OneCare
diff --git a/app/templates/login.html b/app/templates/login.html index 9bed0d5..5f7d476 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -4,11 +4,9 @@
-
- - - -
+ + Denya Developers

Denya OneCare

Sign in to your dashboard

diff --git a/tests/test_branding_assets.py b/tests/test_branding_assets.py new file mode 100644 index 0000000..d98fe11 --- /dev/null +++ b/tests/test_branding_assets.py @@ -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 . +""" + +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 (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 diff --git a/tests/test_whatsapp_demo_number.py b/tests/test_whatsapp_demo_number.py new file mode 100644 index 0000000..c7bb2d7 --- /dev/null +++ b/tests/test_whatsapp_demo_number.py @@ -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