- 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>
308 lines
11 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|