- Component v2/components/feedback_widget.html: Button unten links oberhalb der Queue, Klick oeffnet Modal mit vorausgefuellten Kontext-Feldern (URL, Drucksache, Viewport, User-Agent, letzte 15 Klicks, letzte 10 Console-Errors, letzte 5 Page-Loads). Eingaben: Titel, Beschreibung, optional Screenshot - Audit-Trail-Sammler in localStorage (Ringbuffer 30 Klicks, 10 Errors) - Screenshot via self-hosted html2canvas 1.4.1 (194 KB unter app/static/v2/lib/) - Backend POST /api/feedback (rate-limit 5/h): - validiert + html-strippt Inputs - erstellt Gitea-Issue per API mit Label 'feedback' (Label wird idempotent angelegt) - laedt Screenshot als Issue-Asset hoch (Gitea Issue-Attachment-API) - 4 neue Settings: gitea_token, gitea_api_url, gitea_repo_owner, gitea_repo_name - Server .env um GITEA_TOKEN ergaenzt - 10 neue Unit-Tests (mit gemocktem httpx) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
6.1 KiB
Python
160 lines
6.1 KiB
Python
"""Unit-Tests für /api/feedback — gemockter Gitea-Call.
|
|
|
|
Prüft:
|
|
- Issue-Body wird korrekt aus Eingaben + Audit-Trail zusammengebaut
|
|
- Endpoint antwortet mit issue_id und issue_url
|
|
- Rate-Limit-Decorator ist deklariert
|
|
- Kein Token → 503
|
|
"""
|
|
import json
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
# Skip falls die App-Abhängigkeiten nicht importierbar sind
|
|
try:
|
|
from fastapi.testclient import TestClient
|
|
from app.main import app, _strip_html, _gitea_ensure_label
|
|
_HAS_APP = True
|
|
except ImportError:
|
|
_HAS_APP = False
|
|
|
|
pytestmark = pytest.mark.skipif(not _HAS_APP, reason="app.main not importable")
|
|
|
|
|
|
# ── _strip_html ──────────────────────────────────────────────────────────────
|
|
|
|
class TestStripHtml:
|
|
def test_removes_tags(self):
|
|
assert _strip_html("<b>hallo</b>", 200) == "hallo"
|
|
|
|
def test_max_len(self):
|
|
assert len(_strip_html("a" * 300, 100)) == 100
|
|
|
|
def test_empty(self):
|
|
assert _strip_html("", 200) == ""
|
|
|
|
def test_no_tags(self):
|
|
assert _strip_html("plain text", 200) == "plain text"
|
|
|
|
|
|
# ── /api/feedback Endpoint ───────────────────────────────────────────────────
|
|
|
|
class TestFeedbackEndpoint:
|
|
"""Smoke-Tests mit gemocktem httpx-Client + gemocktem gitea_token."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_gitea(self):
|
|
"""Patches settings.gitea_token und httpx.AsyncClient."""
|
|
# settings.gitea_token setzen
|
|
with patch("app.main.settings") as mock_settings:
|
|
mock_settings.gitea_token = "fake-token-123"
|
|
mock_settings.gitea_api_url = "https://repo.example.com/api/v1"
|
|
mock_settings.gitea_repo_owner = "testowner"
|
|
mock_settings.gitea_repo_name = "testrepo"
|
|
|
|
# httpx.AsyncClient mocken
|
|
mock_resp_labels = MagicMock()
|
|
mock_resp_labels.status_code = 200
|
|
mock_resp_labels.json.return_value = [{"id": 7, "name": "feedback"}]
|
|
|
|
mock_resp_issue = MagicMock()
|
|
mock_resp_issue.status_code = 201
|
|
mock_resp_issue.json.return_value = {
|
|
"number": 42,
|
|
"html_url": "https://repo.example.com/testowner/testrepo/issues/42",
|
|
}
|
|
|
|
async_client = AsyncMock()
|
|
async_client.get.return_value = mock_resp_labels
|
|
async_client.post.return_value = mock_resp_issue
|
|
async_client.__aenter__ = AsyncMock(return_value=async_client)
|
|
async_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("httpx.AsyncClient", return_value=async_client):
|
|
self._async_client = async_client
|
|
yield
|
|
|
|
def test_happy_path_returns_issue_url(self):
|
|
client = TestClient(app)
|
|
resp = client.post("/api/feedback", data={
|
|
"titel": "Test-Bug",
|
|
"beschreibung": "Etwas ist kaputt",
|
|
"url": "https://gwoe.toppyr.de/antrag/NRW-18/1234",
|
|
"drucksache": "NRW-18/1234",
|
|
"viewport": "1440x900",
|
|
"user_agent": "TestAgent/1.0",
|
|
"klicks_json": json.dumps([{"t": "2026-04-25T10:00:00Z", "el": "button.v2-nav-item", "txt": "Durchsuchen"}]),
|
|
"errors_json": json.dumps([]),
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["issue_id"] == 42
|
|
assert "issues/42" in data["issue_url"]
|
|
|
|
def test_issue_body_contains_drucksache(self):
|
|
"""Stellt sicher, dass die Drucksachen-Nummer im POST-Payload auftaucht."""
|
|
client = TestClient(app)
|
|
client.post("/api/feedback", data={
|
|
"titel": "Body-Check",
|
|
"beschreibung": "Details",
|
|
"drucksache": "BY-18/9999",
|
|
"klicks_json": "[]",
|
|
"errors_json": "[]",
|
|
})
|
|
# Zweiter Post-Call ist der Issue-Create-Call
|
|
calls = self._async_client.post.call_args_list
|
|
issue_call = next((c for c in calls if "/issues" in str(c)), None)
|
|
assert issue_call is not None
|
|
body_arg = issue_call.kwargs.get("json", {}).get("body", "")
|
|
assert "BY-18/9999" in body_arg
|
|
|
|
def test_missing_titel_returns_422(self):
|
|
client = TestClient(app)
|
|
resp = client.post("/api/feedback", data={
|
|
"beschreibung": "Ohne Titel",
|
|
"klicks_json": "[]",
|
|
"errors_json": "[]",
|
|
})
|
|
assert resp.status_code == 422
|
|
|
|
def test_missing_beschreibung_returns_422(self):
|
|
client = TestClient(app)
|
|
resp = client.post("/api/feedback", data={
|
|
"titel": "Ohne Beschreibung",
|
|
"klicks_json": "[]",
|
|
"errors_json": "[]",
|
|
})
|
|
assert resp.status_code == 422
|
|
|
|
def test_html_stripped_from_titel(self):
|
|
"""XSS im Titel wird entfernt."""
|
|
client = TestClient(app)
|
|
client.post("/api/feedback", data={
|
|
"titel": "<script>alert(1)</script>Bug",
|
|
"beschreibung": "XSS-Test",
|
|
"klicks_json": "[]",
|
|
"errors_json": "[]",
|
|
})
|
|
calls = self._async_client.post.call_args_list
|
|
issue_call = next((c for c in calls if "/issues" in str(c)), None)
|
|
if issue_call:
|
|
title_arg = issue_call.kwargs.get("json", {}).get("title", "")
|
|
assert "<script>" not in title_arg
|
|
|
|
def test_no_token_returns_503(self):
|
|
"""Ohne konfiguriertes Token gibt es 503."""
|
|
with patch("app.main.settings") as s:
|
|
s.gitea_token = ""
|
|
s.gitea_api_url = "https://repo.example.com/api/v1"
|
|
s.gitea_repo_owner = "testowner"
|
|
s.gitea_repo_name = "testrepo"
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
resp = client.post("/api/feedback", data={
|
|
"titel": "Test",
|
|
"beschreibung": "Kein Token",
|
|
"klicks_json": "[]",
|
|
"errors_json": "[]",
|
|
})
|
|
assert resp.status_code == 503
|