Files
deklarix/internal/web/antrag_handlers_test.go
noroot 8a8295dacd feat: Löschfristen je Datenklasse, Passwort-Zurücksetzen, DPF-Recherche
- Löschfristen (Migration 0020): pro Mandant einstellbar statt fest im
  Regelwerk, da die DSGVO selbst keine festen Fristen nennt (Art. 5
  Abs. 1 lit. e). Jede Firma wird mit risikogestaffelten Vorschlagswerten
  vorbelegt, loeschfrist_max_tage wird jetzt tatsächlich hart gegen
  Werkzeuge gefiltert (schließt rules/OPEN.md Punkt 4).
- Neues internal/mail-Paket (SMTP-Versand + Test-Doppel) und darauf
  aufbauend Passwort-Zurücksetzen (Migration 0019) - bisher nur als
  Absicht in der Rollentabelle genannt, nie gebaut.
- dpf_zertifiziert für alle 20 Katalogeinträge gegen das offizielle
  DPF-Register recherchiert und in der Produktions-DB aktualisiert
  (12 zertifiziert, 5 recherchiert-nicht-gefunden, 3 nicht anwendbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-01 07:38:12 +02:00

308 lines
11 KiB
Go

package web_test
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"testing"
"github.com/netcell-it/deklarix/internal/store"
)
func fullAntragForm() url.Values {
return url.Values{
"titel": {"Angebotstexte generieren"}, "beschreibung": {"KI schreibt Angebotstexte"},
"ergebnis": {"Fertiger Text zur Freigabe"}, "haeufigkeit": {"gelegentlich"},
"b1": {"ja"}, "b2": {"nein"}, "b3": {"nein"}, "b4": {"nein"}, "b5": {"nein"}, "b6": {"nein"}, "b7": {"nein"},
"c1": {"nein"}, "c2": {"nein"}, "c2_folge": {"keine"}, "c3": {"nein"}, "c3_art": {"keine"},
"c4": {"nein"}, "c5": {"ja"},
"d_werkzeug_freitext": {"ChatGPT"}, "d_zugang": {"firma"}, "d_geraet": {"firma"},
}
}
func TestAntragNewFormRendersFragebogen(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
resp := getWithCookie(t, s, cookie, "/antraege/neu")
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
body := resp.Body.String()
for _, want := range []string{`name="titel"`, `name="b1"`, `name="c2_folge"`, `name="d_zugang"`} {
if !strings.Contains(body, want) {
t.Errorf("expected %q in the Fragebogen, got: %s", want, body)
}
}
}
func TestAntragCreateThenDetailShowsAnswers(t *testing.T) {
s, fs, cookie := newAuthedTestServer(t)
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String())
}
loc := resp.Header().Get("Location")
if !strings.HasPrefix(loc, "/antraege/") {
t.Fatalf("Location = %q, want a redirect to the new antrag", loc)
}
var antragID string
for id := range fs.antraege {
antragID = id
}
antrag, err := fs.GetAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetAntrag: %v", err)
}
if antrag.Status != "eingereicht" {
t.Fatalf("Status = %q, want eingereicht", antrag.Status)
}
var antworten map[string]any
if err := json.Unmarshal(antrag.Antworten, &antworten); err != nil {
t.Fatalf("Antworten unmarshal: %v", err)
}
if antworten["b1"] != "ja" {
t.Errorf("b1 = %v, want ja", antworten["b1"])
}
if antworten["c5"] != true {
t.Errorf("c5 = %v, want true", antworten["c5"])
}
detailResp := getWithCookie(t, s, cookie, loc)
if detailResp.Code != http.StatusOK {
t.Fatalf("detail status = %d, body: %s", detailResp.Code, detailResp.Body.String())
}
body := detailResp.Body.String()
if !strings.Contains(body, "Angebotstexte generieren") {
t.Errorf("expected the antrag title on the detail page, got: %s", body)
}
if !strings.Contains(body, "Namen, E-Mail-Adressen oder andere Angaben zu Personen") {
t.Errorf("expected the b1 Fragebogen-Frage on the detail page, got: %s", body)
}
if !strings.Contains(body, "Merkt der Empfänger, dass es von einer KI stammt?") {
t.Errorf("expected the c5 Fragebogen-Frage on the detail page, got: %s", body)
}
}
func TestAntragCreateRejectsMissingFields(t *testing.T) {
s, _, cookie := newAuthedTestServer(t)
resp := postForm(t, s, cookie, "/antraege", url.Values{"titel": {"Nur Titel"}})
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for missing required fields", resp.Code)
}
}
func TestAntragListShowsOnlyOwnAntraege(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
if resp := postForm(t, s, cookieA, "/antraege", fullAntragForm()); resp.Code != http.StatusSeeOther {
t.Fatalf("create (A) status = %d, body: %s", resp.Code, resp.Body.String())
}
listA := getWithCookie(t, s, cookieA, "/antraege")
if !strings.Contains(listA.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected Mandant A to see their own antrag, got: %s", listA.Body.String())
}
listB := getWithCookie(t, s, cookieB, "/antraege")
if strings.Contains(listB.Body.String(), "Angebotstexte generieren") {
t.Errorf("expected Mandant B to see no antrag, got: %s", listB.Body.String())
}
}
func TestAntragDetailRejectsForeignUser(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookieA := seedAccount(t, fs, "Mandant A", "a@example.com")
cookieB := seedAccount(t, fs, "Mandant B", "b@example.com")
createResp := postForm(t, s, cookieA, "/antraege", fullAntragForm())
antragURL := createResp.Header().Get("Location")
resp := getWithCookie(t, s, cookieB, antragURL)
if resp.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for a foreign user's antrag", resp.Code)
}
}
// fullAntragForm liefert b1=ja (Datenklasse personenbezogen) und
// c1..c3=nein (keine der verboten/hochrisiko/transparenzpflicht-
// Varianten trifft zu) — die Einstufung fällt damit auf die
// Auffangregel "minimal" zurück (siehe rules/kivo_einstufung.yaml).
func TestAntragCreateComputesBewertung(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var antragID string
for id := range fs.antraege {
antragID = id
}
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if b.Datenklasse != "personenbezogen" {
t.Errorf("Datenklasse = %q, want personenbezogen", b.Datenklasse)
}
if b.Einstufung != "minimal" {
t.Errorf("Einstufung = %q, want minimal", b.Einstufung)
}
if b.Verboten {
t.Error("Verboten = true, want false")
}
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
if !strings.Contains(detail.Body.String(), "Das System entscheidet nicht") {
t.Errorf("expected the mandatory disclaimer on the detail page, got: %s", detail.Body.String())
}
}
// TestAntragCreateVerbotenSkipsWerkzeugSearch prüft die K.-o.-Prüfung:
// c3=ja mit c3_art=social_scoring löst "verboten" aus (Art. 5 KI-VO) —
// dann darf keine Werkzeugsuche stattfinden (siehe CLAUDE.md,
// Bewertungslogik).
func TestAntragCreateVerbotenSkipsWerkzeugSearch(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
form := fullAntragForm()
form.Set("c3", "ja")
form.Set("c3_art", "social_scoring")
resp := postForm(t, s, cookie, "/antraege", form)
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var antragID string
for id := range fs.antraege {
antragID = id
}
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if !b.Verboten || b.Einstufung != "verboten" {
t.Fatalf("Bewertung = %+v, want Verboten=true, Einstufung=verboten", b)
}
if len(b.ZulaessigeWerkzeuge) != 0 || len(b.AusgeschlosseneWerkzeuge) != 0 {
t.Errorf("expected no werkzeug search for a verboten Bewertung, got %+v", b)
}
}
// TestAntragCreateFiltersWerkzeugeByAnforderungen prüft den harten
// Filter: personenbezogen verlangt avv_erforderlich — ein Werkzeug ohne
// AVV wird ausgeschlossen und im Ergebnis mit Begründung genannt statt
// stillschweigend wegzufallen, ein Werkzeug mit AVV bleibt zulässig.
func TestAntragCreateFiltersWerkzeugeByAnforderungen(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
fs.werkzeuge["werkzeug-ok"] = store.Werkzeug{
ID: "werkzeug-ok", Name: "ChatGPT Enterprise",
AVVVerfuegbar: true, TrainingStandard: true,
}
fs.werkzeuge["werkzeug-ohne-avv"] = store.Werkzeug{
ID: "werkzeug-ohne-avv", Name: "Freies KI-Tool",
AVVVerfuegbar: false, TrainingStandard: true,
}
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var antragID string
for id := range fs.antraege {
antragID = id
}
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "werkzeug-ok" {
t.Errorf("ZulaessigeWerkzeuge = %v, want [werkzeug-ok]", b.ZulaessigeWerkzeuge)
}
if len(b.AusgeschlosseneWerkzeuge) != 1 || b.AusgeschlosseneWerkzeuge[0].WerkzeugID != "werkzeug-ohne-avv" {
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want [werkzeug-ohne-avv]", b.AusgeschlosseneWerkzeuge)
}
if got := b.AusgeschlosseneWerkzeuge[0].NichtErfuellt; len(got) != 1 || got[0] != "avv_erforderlich" {
t.Errorf("NichtErfuellt = %v, want [avv_erforderlich]", got)
}
detail := getWithCookie(t, s, cookie, resp.Header().Get("Location"))
body := detail.Body.String()
if !strings.Contains(body, "ChatGPT Enterprise") || !strings.Contains(body, "Freies KI-Tool") {
t.Errorf("expected both werkzeuge (by name) on the detail page, got: %s", body)
}
}
func TestAntragCreateFiltertNachLoeschfrist(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
cookie := seedAccount(t, fs, "Test-Mandant", "test@example.com")
sess := fs.sessions[cookie.Value]
accountID := fs.users[sess.UserID].AccountID
// fullAntragForm loest Datenklasse "personenbezogen" aus (b1=ja).
fs.loeschfristen[accountID] = map[string]int{"personenbezogen": 30}
fs.werkzeuge["kurze-aufbewahrung"] = store.Werkzeug{
ID: "kurze-aufbewahrung", Name: "Kurz-Tool",
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(14),
}
fs.werkzeuge["lange-aufbewahrung"] = store.Werkzeug{
ID: "lange-aufbewahrung", Name: "Lang-Tool",
AVVVerfuegbar: true, TrainingStandard: true, AufbewahrungTage: intPtr(90),
}
fs.werkzeuge["unbekannte-aufbewahrung"] = store.Werkzeug{
ID: "unbekannte-aufbewahrung", Name: "Unbekannt-Tool",
AVVVerfuegbar: true, TrainingStandard: true,
}
resp := postForm(t, s, cookie, "/antraege", fullAntragForm())
if resp.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String())
}
var antragID string
for id := range fs.antraege {
antragID = id
}
b, err := fs.GetLatestBewertungForAntrag(context.Background(), antragID)
if err != nil {
t.Fatalf("GetLatestBewertungForAntrag: %v", err)
}
if len(b.ZulaessigeWerkzeuge) != 1 || b.ZulaessigeWerkzeuge[0] != "kurze-aufbewahrung" {
t.Fatalf("ZulaessigeWerkzeuge = %v, want [kurze-aufbewahrung]", b.ZulaessigeWerkzeuge)
}
if len(b.AusgeschlosseneWerkzeuge) != 2 {
t.Fatalf("AusgeschlosseneWerkzeuge = %+v, want 2 ausgeschlossene", b.AusgeschlosseneWerkzeuge)
}
}
func intPtr(i int) *int { return &i }
func TestBetreiberRedirectedAwayFromAntragPages(t *testing.T) {
fs := newFakeStore()
s := newServer(t, fs)
betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber")
for _, path := range []string{"/antraege", "/antraege/neu"} {
resp := getWithCookie(t, s, betreiberCookie, path)
if resp.Code != http.StatusSeeOther {
t.Errorf("GET %s status = %d, want 303 redirect for a betreiber", path, resp.Code)
}
}
}