feat(audit-report): deterministischer Textreport je Audit (MD + PDF) + Bericht-Tab
Firmen-tauglicher Bericht aus den Snapshot-Modulergebnissen (kein Re-Crawl, kein LLM): Einleitung, Testumfang+Methodik, Management-Summary (4-Status), Detail- befunde je Modul, Maßnahmen, Rechtlicher Hinweis. Co-Pilot-Tonalität, Tracking- statt Cookie-Rohzahl, Norm nur referenziert (kein Normtext). - audit_report.py: assemble_report (pur) + render_markdown + render_pdf (reportlab) - snapshot_check_routes: GET /report (struktur+md) + GET /report.pdf - Frontend: AuditReportTab + Proxys (report, report/pdf) + "Bericht"-Tab - Tests: 5 Assembler (compliance/tests → CI-geprüft) + 1 Vitest Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AuditReportTab — rendert den deterministischen Audit-Textreport eines
|
||||
* Snapshots (Sektionen aus /report, kein Re-Crawl) + Download als PDF/Markdown.
|
||||
* Bewusst ohne Markdown-Lib + ohne dangerouslySetInnerHTML (Befundtexte können
|
||||
* Site-Inhalte enthalten → XSS-sicher über React-Textknoten).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
type Section = { title: string; level: number; body?: string }
|
||||
type Report = { meta?: Record<string, unknown>; sections?: Section[]; totals?: Record<string, unknown> }
|
||||
|
||||
function Inline({ text }: { text: string }) {
|
||||
// **fett** sicher rendern; _kursiv_-Marker entfernen.
|
||||
const parts = text.split(/\*\*(.+?)\*\*/g)
|
||||
return <>{parts.map((p, i) => (i % 2
|
||||
? <strong key={i}>{p}</strong>
|
||||
: <React.Fragment key={i}>{p.replace(/_/g, '')}</React.Fragment>))}</>
|
||||
}
|
||||
|
||||
function Body({ body }: { body: string }) {
|
||||
const out: React.ReactNode[] = []
|
||||
let bullets: string[] = []
|
||||
const flush = (k: string) => {
|
||||
if (bullets.length) {
|
||||
const items = bullets
|
||||
out.push(<ul key={'u' + k} className="list-disc ml-5 space-y-1">
|
||||
{items.map((b, j) => <li key={j} className="text-sm text-gray-700"><Inline text={b} /></li>)}
|
||||
</ul>)
|
||||
bullets = []
|
||||
}
|
||||
}
|
||||
body.split('\n').map(l => l.trim()).filter(Boolean).forEach((l, i) => {
|
||||
if (l.startsWith('- ')) { bullets.push(l.slice(2)) }
|
||||
else { flush('p' + i); out.push(<p key={i} className="text-sm text-gray-700"><Inline text={l} /></p>) }
|
||||
})
|
||||
flush('end')
|
||||
return <div className="space-y-1.5">{out}</div>
|
||||
}
|
||||
|
||||
export function AuditReportTab({ snapshotId }: { snapshotId: string }) {
|
||||
const [rep, setRep] = useState<Report | null>(null)
|
||||
const [md, setMd] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}/report`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (cancelled) return
|
||||
if (d?.error) setErr(d.error)
|
||||
else { setRep(d.report); setMd(d.markdown || '') }
|
||||
})
|
||||
.catch(e => { if (!cancelled) setErr(String(e)) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [snapshotId])
|
||||
|
||||
const downloadMd = () => {
|
||||
const blob = new Blob([md], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = 'audit-report.md'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-500">Bericht wird erstellt…</div>
|
||||
if (err || !rep) return <div className="text-sm text-red-600">{err || 'Kein Bericht verfügbar.'}</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<a href={`/api/sdk/v1/agent/snapshots/${snapshotId}/report/pdf`}
|
||||
target="_blank" rel="noopener"
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700">
|
||||
PDF herunterladen
|
||||
</a>
|
||||
<button onClick={downloadMd}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-blue-200 text-blue-700 hover:bg-blue-50">
|
||||
Markdown herunterladen
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-200 rounded-xl p-5 space-y-3 bg-white">
|
||||
{(rep.sections || []).map((s, i) => s.level <= 2 ? (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<h2 className="text-base font-semibold text-gray-900 border-b border-gray-100 pb-1">{s.title}</h2>
|
||||
{s.body && <Body body={s.body} />}
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="space-y-1 ml-1">
|
||||
<h3 className="text-sm font-semibold text-gray-800">{s.title}</h3>
|
||||
{s.body && <Body body={s.body} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
import { AuditReportTab } from '../AuditReportTab'
|
||||
|
||||
function mockFetch(body: unknown) {
|
||||
return vi.fn(async () => ({ ok: true, status: 200, json: async () => body })) as unknown as typeof fetch
|
||||
}
|
||||
|
||||
describe('AuditReportTab', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('rendert Sektionen + Download-Buttons', async () => {
|
||||
const data = {
|
||||
report: {
|
||||
sections: [
|
||||
{ title: 'Einleitung', level: 2, body: 'Dieser Bericht fasst die Analyse zusammen.' },
|
||||
{ title: 'Empfohlene Maßnahmen', level: 2, body: '- **[Hoch]** Tracking erst nach Consent laden.' },
|
||||
],
|
||||
},
|
||||
markdown: '# Bericht\n',
|
||||
}
|
||||
vi.stubGlobal('fetch', mockFetch(data))
|
||||
render(<AuditReportTab snapshotId="abc" />)
|
||||
expect(await screen.findByText('Einleitung')).toBeInTheDocument()
|
||||
expect(screen.getByText('Empfohlene Maßnahmen')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Tracking erst nach Consent/)).toBeInTheDocument()
|
||||
expect(screen.getByText('PDF herunterladen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown herunterladen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user