feat(b17): DSMS-CID-Anchor für Audit-Walk-Video (Stufe 3, #7)
Video + walk.json werden nach Aufnahme zu DSMS-IPFS hochgeladen.
Die zurückgegebenen CIDs sind manipulationssichere Audit-Anker —
Reviewer können das Walk-Video Monate später noch verifizieren und
auf Unverändertheit prüfen.
consent-tester:
- _upload_to_dsms(): Best-Effort-Upload zu /api/v1/documents
(Bearer-Token, document_type=audit_walk_video|meta). DSMS-Down
bricht den Walk nicht ab — CID fehlt einfach im result.
- record_audit_walk(): nach video.webm + walk.json erzeugt, beide
hochladen. walk.json wird re-written sodass es BEIDE CIDs
selbstreferenziell enthält.
- ENV: DSMS_GATEWAY_URL + DSMS_BEARER konfigurierbar.
backend:
- _b17_wiring._publicize_gateway_url(): DSMS gibt intern
http://dsms-node:8080/ipfs/{cid} zurück. Für die Audit-Mail
wird das via env DSMS_PUBLIC_GATEWAY (default
https://dsms-dev.breakpilot.ai) durch eine extern erreichbare
URL ersetzt.
- Render-Block: gelber DSMS-Anchor-Hinweis mit Video-CID +
walk.json-CID, beide als klickbare Links zur public Gateway.
Real-World-Smoke gegen Elli:
- Video-CID: QmbdFwtSymPuWGYYdC6eNZ1eEvVLsTYmoRRxEo5L6BXgwt
- walk.json-CID: QmWaTqwZq4KVd5wYFVAKB12uZtAosPqoG1X4m1azysXYJi
- DSMS-Upload erfolgreich, gateway_url im response
Tests: 12/12 grün (+2 für DSMS-Anchor-Render-Pfade inkl.
Internal-Host → Public-Gateway-Rewrite).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
@@ -21,6 +22,24 @@ from ._constants import CONSENT_TESTER_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optionaler Override für die öffentliche IPFS-Gateway-URL. DSMS gibt
|
||||
# intern http://dsms-node:8080/ipfs/{cid} zurück — für die Mail brauchen
|
||||
# Reviewer aber eine extern erreichbare URL.
|
||||
DSMS_PUBLIC_GATEWAY = os.environ.get(
|
||||
"DSMS_PUBLIC_GATEWAY", "https://dsms-dev.breakpilot.ai",
|
||||
)
|
||||
|
||||
|
||||
def _publicize_gateway_url(internal_url: str) -> str:
|
||||
"""Replace internal dsms-node host with the public gateway."""
|
||||
if not internal_url:
|
||||
return ""
|
||||
return internal_url.replace(
|
||||
"http://dsms-node:8080", DSMS_PUBLIC_GATEWAY,
|
||||
).replace(
|
||||
"http://bp-compliance-dsms-node:8080", DSMS_PUBLIC_GATEWAY,
|
||||
)
|
||||
|
||||
|
||||
async def run_b17(state: dict) -> None:
|
||||
"""Trigger walk recording + store metadata in state."""
|
||||
@@ -81,6 +100,36 @@ def _render(walk: dict) -> str:
|
||||
walk_link = _video_link(wid)
|
||||
meta_link = f"{CONSENT_TESTER_URL}/audit-walks/{wid}/walk.json"
|
||||
|
||||
# Stufe-3 DSMS-Anchor
|
||||
video_dsms = (video.get("dsms") or {})
|
||||
meta_dsms = (walk.get("walk_json_dsms") or {})
|
||||
video_cid = video_dsms.get("cid") or ""
|
||||
meta_cid = meta_dsms.get("cid") or ""
|
||||
video_gw = _publicize_gateway_url(video_dsms.get("gateway_url") or "")
|
||||
meta_gw = _publicize_gateway_url(meta_dsms.get("gateway_url") or "")
|
||||
dsms_html = ""
|
||||
if video_cid or meta_cid:
|
||||
parts = []
|
||||
if video_cid:
|
||||
link = (f"<a href='{html.escape(video_gw)}' style='color:#0369a1;'>"
|
||||
f"<code>{html.escape(video_cid[:20])}…</code></a>"
|
||||
if video_gw else
|
||||
f"<code>{html.escape(video_cid)}</code>")
|
||||
parts.append(f"Video-CID: {link}")
|
||||
if meta_cid:
|
||||
link = (f"<a href='{html.escape(meta_gw)}' style='color:#0369a1;'>"
|
||||
f"<code>{html.escape(meta_cid[:20])}…</code></a>"
|
||||
if meta_gw else
|
||||
f"<code>{html.escape(meta_cid)}</code>")
|
||||
parts.append(f"walk.json-CID: {link}")
|
||||
dsms_html = (
|
||||
"<p style='margin:0 0 8px;padding:6px 10px;background:#fef3c7;"
|
||||
"border-radius:4px;font-size:12px;color:#78350f;'>"
|
||||
"<strong>🔒 DSMS-Anchor (manipulationssicher):</strong> "
|
||||
+ " · ".join(parts) +
|
||||
"</p>"
|
||||
)
|
||||
|
||||
rows = []
|
||||
for a in actions:
|
||||
ts = (a.get("timestamp") or "")[11:19] # HH:MM:SS
|
||||
@@ -126,6 +175,7 @@ def _render(walk: dict) -> str:
|
||||
f"{nav_count} Compliance-Seiten besucht, jede 4 Sek "
|
||||
"verweilt — Reviewer kann den Audit-Walk nachverfolgen."
|
||||
"</p>"
|
||||
+ dsms_html +
|
||||
"<table style='font-size:12px;width:100%;border-collapse:collapse;"
|
||||
"background:#fff;border-radius:4px;'>"
|
||||
"<thead><tr style='background:#e0f2fe;'>"
|
||||
|
||||
@@ -72,6 +72,31 @@ class TestRender:
|
||||
html = _render(walk)
|
||||
assert "Keine Akkordeons gefunden" in html
|
||||
|
||||
def test_dsms_anchor_rendered_when_cid_present(self):
|
||||
walk = dict(_FAKE_WALK)
|
||||
walk["video"] = dict(walk["video"])
|
||||
walk["video"]["dsms"] = {
|
||||
"cid": "QmTestCidVideoXX1234567890ABCDEFGHJKLMN",
|
||||
"gateway_url": "http://dsms-node:8080/ipfs/QmTestCidVideo",
|
||||
}
|
||||
walk["walk_json_dsms"] = {
|
||||
"cid": "QmTestCidMetaXX1234567890ABCDEFGHJKLMN",
|
||||
"gateway_url": "http://dsms-node:8080/ipfs/QmTestCidMeta",
|
||||
}
|
||||
html = _render(walk)
|
||||
assert "DSMS-Anchor" in html
|
||||
assert "QmTestCidVideoXX1234" in html
|
||||
# Internal gateway-host must be rewritten to public for the mail
|
||||
assert "dsms-node:8080" not in html
|
||||
|
||||
def test_no_dsms_block_when_cid_absent(self):
|
||||
walk = dict(_FAKE_WALK)
|
||||
walk["video"] = dict(walk["video"])
|
||||
walk["video"].pop("dsms", None)
|
||||
walk.pop("walk_json_dsms", None)
|
||||
html = _render(walk)
|
||||
assert "DSMS-Anchor" not in html
|
||||
|
||||
|
||||
class TestRunB17:
|
||||
def test_no_request_skipped(self):
|
||||
|
||||
Reference in New Issue
Block a user