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.
81 lines
3.3 KiB
Python
81 lines
3.3 KiB
Python
"""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
|