secubox-deb/packages/secubox-billets/tests/test_archive.py
CyberMind-FR a95f157eb1 feat(billets): communiqué style + embed snapshot vignette + portable HTML archive (ref #851)
- Per-billet 'style' (default|communique). Communiqué permalink = the poster
  mockup (Space Grotesk/JetBrains Mono, ROUTE side-band, TRANSMISSION frame,
  slogans/footer); funky feed unchanged.
- Embed snapshot vignette: api/services/snapshot.py tries headless Chromium
  (guarded/optional), falls back to SSRF-guarded og:image, re-encoded via the
  media service. Captured off-loop on save (communiqué billets, cached per
  embed_url). Public render shows the vignette; click-to-load the live iframe.
- Portable single-HTML archive: GET /admin/billets/{id}/archive.html — self-
  contained (inlined CSS, system fonts, media+snapshot as base64 data-URIs,
  embed = snapshot linking to original, no iframe). Off-loop render.
- migration 0003 (style + embed_snapshot); models/repo/admin/main wired.
- CSP: style-src adds 'unsafe-inline' so the WAF-injected health-banner's
  dynamic <style> renders (was blocked → banner fell unstyled to page bottom);
  script-src stays 'self'. Google-Fonts relaxation page-scoped to communiqué.
- 118 tests (11 new: snapshot fallback/SSRF, communiqué render, archive self-containment).

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
2026-07-14 07:57:54 +02:00

98 lines
4.0 KiB
Python

# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Portable single-file .html archive: JWT-gated, fully self-contained (CSS
inlined, media + embed vignette as data: URIs), the embed rendered as its
snapshot image LINKING to the original (no live iframe → opens offline)."""
import io
import httpx
import pytest_asyncio
from PIL import Image
from api import repo
from api.main import create_app
from api.services import security as sec
PW = "s3cret-pass-123"
NOW = "2026-07-11T12:00:00Z"
def _png() -> bytes:
buf = io.BytesIO()
Image.new("RGB", (48, 48), (200, 78, 36)).save(buf, format="PNG")
return buf.getvalue()
@pytest_asyncio.fixture
async def client(conn, tmp_path, monkeypatch):
monkeypatch.setenv("BILLETS_MEDIA_DIR", str(tmp_path / "media"))
monkeypatch.setenv("BILLETS_SNAPSHOT_BROWSER", "0")
await repo.create_author(conn, "gk", sec.hash_password(PW), now=NOW,
author_id="01AUTHOR0000000000000000AA")
app = create_app(conn, secret="test-secret-xyz", revisions_dir=str(tmp_path / "revs"))
app.state.clock = lambda: NOW
app.state.http_client = httpx.AsyncClient(transport=httpx.MockTransport(
lambda r: httpx.Response(204)))
app.state.resolver = lambda host, port: [(0, 0, 0, "", ("93.184.216.34", port))]
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://t",
follow_redirects=False) as c:
yield c, conn, tmp_path
async def _login(c):
await c.get("/admin/login")
csrf = c.cookies.get("billets_csrf")
await c.post("/admin/login", data={"username": "gk", "password": PW, "csrf": csrf})
return c.cookies.get("billets_csrf")
async def _seed_billet(c, conn):
csrf = await _login(c)
await c.post("/admin/billets",
data={"body": "Communiqué archivable", "style": "communique",
"action": "publish", "csrf": csrf},
files={"media_files": ("shot.png", _png(), "image/png")})
row = (await repo.list_all(conn))[0]
# reuse the stored media file as the (pretend) embed snapshot vignette
media_row = (await repo.list_media(conn, row["id"]))[0]
await conn.execute("UPDATE billet SET embed_url=? WHERE id=?",
("https://youtube.com/watch?v=z", row["id"]))
await conn.commit()
await repo.set_embed_snapshot(conn, row["id"], media_row["filename"])
return row
async def test_archive_requires_auth(client):
c, conn, _ = client
row = await _seed_billet(c, conn)
# drop the session cookie → must be redirected to login
c.cookies.delete("billets_session")
r = await c.get(f"/admin/billets/{row['id']}/archive.html")
assert r.status_code == 303 and r.headers["location"] == "/admin/login"
async def test_archive_is_self_contained_html(client):
c, conn, _ = client
row = await _seed_billet(c, conn)
r = await c.get(f"/admin/billets/{row['id']}/archive.html")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
assert f'filename="billet-{row["slug"]}.html"' in r.headers.get("content-disposition", "")
body = r.text
assert body.lstrip().startswith("<!DOCTYPE html")
assert "<style>" in body # CSS inlined, no external sheet
assert "fonts.googleapis.com" not in body # offline: system fonts only
assert "data:image/png;base64," in body # media + vignette inlined
# embed = snapshot image LINKING to the original; never a live iframe
assert "<iframe" not in body
assert 'href="https://youtube.com/watch?v=z"' in body
assert "TRANSMISSION" in body # communiqué frame label
async def test_archive_missing_billet_redirects(client):
c, conn, _ = client
await _login(c)
r = await c.get("/admin/billets/01DOESNOTEXIST00000000000A/archive.html")
assert r.status_code == 303 and r.headers["location"] == "/admin"