gwoe-antragspruefer/tests/test_report.py
Dotty Dotter 16ecd31e50 test(#134): report.py Coverage 44.3% → 52.7%
- TestGetScoreColor: alle 5 Branches (>=7 blue, >=4 green, >=2 yellow,
  >=1 orange, sonst red)
- TestGetRatingSymbol: alle 5 Symbole (++, +, ○, −, −−)

Verbleibend (Lines 487-641): WeasyPrint-PDF-Render-Pfade — brauchen
echtes WeasyPrint-Setup, gehoeren in tests/integration/.

Total: 53.2% → 53.4%, 777 → 787 Tests.
2026-04-28 11:13:20 +02:00

245 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests für app.report — insbesondere XSS/XXE-Escape-Verhalten.
Abdeckung der Bug-Klasse aus Issue #57 (Security Audit, Befunde #2 und #6):
das LLM-Output landet direkt in der HTML-Vorlage, die von WeasyPrint
gerendert wird. Ohne Escape kann eine Prompt-Injection mit einem rohen
``<img src="file:///etc/passwd">`` Local File Read im Container auslösen
oder ``<script>``-Payloads im Browser des Lesers ausführen, der den
HTML-Report über ``/result/{job_id}`` öffnet.
Diese Tests bauen ein Assessment mit kontrollierten XSS-Payloads in jedem
LLM-getragenen Feld und verifizieren, dass die generierte HTML die rohen
Payloads nicht enthält und die escapeden Varianten dafür schon.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from app.models import (
Assessment,
Empfehlung,
FraktionScores,
MatrixEntry,
ProgrammScore,
Verbesserung,
Verbesserungspotenzial,
)
from app.report import build_matrix_html, format_redline_html, generate_html_report
# ─────────────────────────────────────────────────────────────────────────────
# format_redline_html unit tests
# ─────────────────────────────────────────────────────────────────────────────
class TestFormatRedlineHtml:
"""``format_redline_html`` muss escape-first-then-replace fahren, sonst
überleben rohe HTML-Tags aus dem LLM die Konvertierung."""
def test_plain_text_passes_through(self):
assert format_redline_html("hallo welt") == "hallo welt"
def test_inserted_marker_becomes_span(self):
assert format_redline_html("**neu**") == '<span class="inserted">neu</span>'
def test_deleted_marker_becomes_span(self):
assert format_redline_html("~~weg~~") == '<span class="deleted">weg</span>'
def test_script_tag_in_input_is_escaped(self):
out = format_redline_html("vor <script>alert(1)</script> nach")
assert "<script>" not in out
assert "&lt;script&gt;" in out
def test_local_file_image_in_input_is_escaped(self):
out = format_redline_html('<img src="file:///etc/passwd">')
assert '<img src="file:///etc/passwd">' not in out
assert "&lt;img" in out
def test_marker_inside_escaped_tag_still_renders(self):
# Edge: ein Angreifer kann seine Payload in **markern** einbetten,
# die Marker müssen weiterhin als spans rendern, der HTML-Inhalt
# aber escaped sein.
out = format_redline_html("**<script>x</script>**")
assert "<span class=\"inserted\">" in out
assert "<script>" not in out
assert "&lt;script&gt;" in out
# ─────────────────────────────────────────────────────────────────────────────
# build_matrix_html title-attribute escape
# ─────────────────────────────────────────────────────────────────────────────
def _minimal_assessment(**overrides) -> Assessment:
"""Build a smallest-possible Assessment with all required fields,
plus deep-link overrides for the field under test."""
base = dict(
drucksache="18/12345",
title="Demo-Antrag",
fraktionen=["CDU"],
datum="2026-04-09",
link=None,
gwoe_score=5.0,
gwoe_begruendung="Begründung.",
gwoe_matrix=[
MatrixEntry(field="A1", label="Lieferant:innen × 1", aspect="kein Aspekt", rating=0),
],
gwoe_schwerpunkt=["Solidarität"],
wahlprogramm_scores=[
FraktionScores(
fraktion="CDU",
wahlprogramm=ProgrammScore(score=5.0, begruendung="ok", zitate=[]),
parteiprogramm=ProgrammScore(score=5.0, begruendung="ok", zitate=[]),
),
],
verbesserungen=[],
staerken=["eine stärke"],
schwaechen=["eine schwäche"],
empfehlung=Empfehlung.UEBERARBEITEN,
verbesserungspotenzial=Verbesserungspotenzial.MITTEL,
themen=[],
antrag_zusammenfassung="zusammenfassung",
antrag_kernpunkte=["punkt eins"],
)
base.update(overrides)
return Assessment(**base)
class TestBuildMatrixHtml:
def test_aspect_with_quote_does_not_break_title_attribute(self):
"""Ein nacktes ``"`` in ``aspect`` darf das ``title="..."``-Attribut
nicht beenden — sonst kann ein folgender ``onmouseover=...``-Token
zu einem echten Event-Handler werden. Entscheidender Check: das
rohe ``"`` ist im Output durch ``&quot;`` ersetzt, sodass das
Attribut geschlossen bleibt."""
a = _minimal_assessment(
gwoe_matrix=[
MatrixEntry(
field="A1",
label="Lieferant:innen × 1",
aspect='" onmouseover="alert(1)',
rating=1,
),
],
)
html = build_matrix_html(a)
# Das rohe " aus dem aspect darf nicht mehr im title-Wert stehen
assert '"" onmouseover' not in html # Attribut-Breakout
assert '" onmouseover="alert' not in html
# Stattdessen muss die escapete Form vorhanden sein
assert "&quot;" in html
# ─────────────────────────────────────────────────────────────────────────────
# generate_html_report end-to-end XSS escape
# ─────────────────────────────────────────────────────────────────────────────
_XSS = "<script>alert('xss')</script>"
_XXE = '<img src="file:///etc/passwd">'
def _build_xss_assessment() -> Assessment:
return _minimal_assessment(
title=f"Antrag {_XSS}",
gwoe_begruendung=f"Begründung mit {_XSS}",
gwoe_schwerpunkt=[f"Schwerpunkt {_XXE}"],
antrag_zusammenfassung=f"Zusammenfassung mit {_XXE}",
antrag_kernpunkte=[f"Kernpunkt mit {_XSS}"],
staerken=[f"Stärke mit {_XSS}"],
schwaechen=[f"Schwäche mit {_XXE}"],
gwoe_matrix=[
MatrixEntry(field="A1", label="Lieferant:innen × 1",
aspect=f"Aspekt {_XSS}", rating=2),
],
wahlprogramm_scores=[
FraktionScores(
fraktion=f"Fraktion {_XSS}",
wahlprogramm=ProgrammScore(score=5.0, begruendung=f"WP {_XXE}", zitate=[]),
parteiprogramm=ProgrammScore(score=5.0, begruendung=f"PP {_XSS}", zitate=[]),
),
],
verbesserungen=[
Verbesserung(
original=f"Original {_XSS}",
vorschlag=f"Vorschlag **mit {_XSS} markup**",
begruendung=f"Begründung {_XXE}",
),
],
)
def test_generate_html_report_escapes_all_llm_payloads(tmp_path: Path):
a = _build_xss_assessment()
out = tmp_path / "report.html"
asyncio.run(generate_html_report(a, out, bundesland="NRW"))
html = out.read_text()
# Negative: keine rohen Angriffs-Strings
assert "<script>" not in html, "rohes <script> aus LLM-Felder im Output"
assert "alert('xss')" not in html
assert 'src="file:///etc/passwd"' not in html, (
"rohes file:// in HTML würde WeasyPrint zum Local-File-Read verleiten"
)
# Positive: escapete Form ist vorhanden (also wurde der Payload überhaupt
# mit-gerendert, nur eben sicher)
assert "&lt;script&gt;" in html
assert "&lt;img" in html
# Format-Redline-Marker müssen weiterhin funktionieren (Vorschlag mit **)
assert '<span class="inserted">' in html
# ─── Coverage-Backfill (#134) ────────────────────────────────────────────────
class TestGetScoreColor:
def test_high_score_blue(self):
from app.report import get_score_color
assert get_score_color(8.5).lower().startswith("#")
assert get_score_color(8.5) == get_score_color(7.0) # gleiche Klasse
def test_mid_score_green(self):
from app.report import get_score_color, COLORS
assert get_score_color(5.0) == COLORS["green"]
def test_low_yellow(self):
from app.report import get_score_color
assert get_score_color(2.5) == "#FFC20E"
def test_very_low_orange(self):
from app.report import get_score_color, COLORS
assert get_score_color(1.5) == COLORS["orange"]
def test_zero_red(self):
from app.report import get_score_color, COLORS
assert get_score_color(0.0) == COLORS["red"]
class TestGetRatingSymbol:
def test_strong_positive(self):
from app.report import get_rating_symbol
assert get_rating_symbol(2) == "++"
assert get_rating_symbol(5) == "++"
def test_positive(self):
from app.report import get_rating_symbol
assert get_rating_symbol(1) == "+"
def test_neutral(self):
from app.report import get_rating_symbol
assert get_rating_symbol(0) == ""
def test_negative(self):
from app.report import get_rating_symbol
assert get_rating_symbol(-1) == ""
def test_strong_negative(self):
from app.report import get_rating_symbol
assert get_rating_symbol(-2) == ""
assert get_rating_symbol(-5) == ""