- 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.
245 lines
9.8 KiB
Python
245 lines
9.8 KiB
Python
"""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 "<script>" 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 "<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 "<script>" 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 ``"`` 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 """ 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 "<script>" in html
|
||
assert "<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) == "−−"
|