Files
breakpilot-compliance/backend-compliance/tests/test_b17_audit_walk.py
T
Benjamin Admin c7d2038ad9 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>
2026-06-07 17:32:34 +02:00

136 lines
4.9 KiB
Python

"""Tests for B17 Audit-Walk-Wiring (Stufe 1)."""
import asyncio
from unittest.mock import patch, MagicMock, AsyncMock
import pytest
from compliance.api.agent_check._b17_wiring import _render, run_b17
_FAKE_WALK = {
"walk_id": "abc123def456",
"url": "https://example.com/",
"started_at": "2026-06-07T10:00:00+00:00",
"completed_at": "2026-06-07T10:00:30+00:00",
"engine": "playwright/webkit",
"viewport": "1280x800",
"actions": [
{"timestamp": "2026-06-07T10:00:00+00:00", "action": "goto",
"url": "https://example.com/", "status": 200},
{"timestamp": "2026-06-07T10:00:02+00:00", "action": "accept_banner",
"result": "clicked", "phrase": "alle akzeptieren"},
{"timestamp": "2026-06-07T10:00:04+00:00",
"action": "discover_footer_links", "count": 3, "links": []},
{"timestamp": "2026-06-07T10:00:06+00:00", "action": "navigate",
"url": "https://example.com/datenschutz",
"anchor_text": "Datenschutz", "status": 200,
"title": "Datenschutzerklärung"},
{"timestamp": "2026-06-07T10:00:12+00:00",
"action": "expand_accordions", "expanded": 5, "max": 25},
],
"video": {
"filename": "video.webm",
"size_bytes": 512000,
"sha256": "a1b2c3d4e5f67890fedcba0987654321ffffeeeeddddccccbbbbaaaa00001111",
},
}
class TestRender:
def test_renders_walk_id_and_link(self):
html = _render(_FAKE_WALK)
assert "abc123def456" in html
assert "video.webm" in html
assert "walk.json" in html
def test_includes_sha_prefix(self):
html = _render(_FAKE_WALK)
# First 12 chars of sha
assert "a1b2c3d4e5f6" in html
def test_action_table_lists_all_actions(self):
html = _render(_FAKE_WALK)
# All five actions + header appear as <tr>
assert html.count("<tr>") >= 5
def test_nav_count_reflects_navigate_actions(self):
html = _render(_FAKE_WALK)
# 1 navigate in the fixture
assert "1 Compliance-Seiten" in html
def test_renders_accordion_expansion_count(self):
html = _render(_FAKE_WALK)
assert "5 Akkordeon" in html
def test_accordion_zero_renders_neutral_text(self):
walk = dict(_FAKE_WALK)
walk["actions"] = [
{"timestamp": "...", "action": "expand_accordions",
"expanded": 0, "max": 25},
]
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):
state = {}
asyncio.run(run_b17(state))
assert "audit_walk" not in state
def test_no_url_skipped(self):
state = {"req": MagicMock(documents=[MagicMock(url="")])}
asyncio.run(run_b17(state))
assert "audit_walk" not in state
def test_consent_tester_failure_skipped(self):
req = MagicMock(documents=[MagicMock(url="https://example.com/dse")])
state = {"req": req}
with patch(
"compliance.api.agent_check._b17_wiring.httpx.AsyncClient"
) as mock_client:
instance = mock_client.return_value.__aenter__.return_value
instance.post = AsyncMock(side_effect=Exception("nope"))
asyncio.run(run_b17(state))
assert "audit_walk" not in state
def test_success_populates_state(self):
req = MagicMock(documents=[MagicMock(url="https://example.com/dse")])
state = {"req": req}
resp = MagicMock(status_code=200)
resp.json = MagicMock(return_value=_FAKE_WALK)
with patch(
"compliance.api.agent_check._b17_wiring.httpx.AsyncClient"
) as mock_client:
instance = mock_client.return_value.__aenter__.return_value
instance.post = AsyncMock(return_value=resp)
asyncio.run(run_b17(state))
assert state["audit_walk"]["walk_id"] == "abc123def456"
assert "video.webm" in state["audit_walk_html"]