package web import ( "context" "encoding/json" "net/http" "net/url" "strings" "testing" "time" "munin/internal/auth" "munin/internal/store" ) // Tests zu den Ergänzungen der Weboberfläche vom 2026-10-16 (AUSBAUSTUFEN.md // 4.7 Stufe 2 und 5.7–5.14): veraltete Einträge, „Auch verwandt", „Gibt es // schon?", ungespeicherte Änderungen, Tastenkürzel, Protokollfilter, // Systemseite, Ablaufdatum für Keys und Suche über alle Projekte. // create schreibt einen Eintrag direkt in den Store. func create(t *testing.T, db *store.Store, project store.Project, w store.EntryWrite) { t.Helper() w.ProjectID = project.ID if w.Source == "" { w.Source = store.SourceHuman } if _, err := db.PutEntry(context.Background(), w); err != nil { t.Fatal(err) } } // TestStaleEntries: Die Liste markiert Einträge, die seit über einem // Jahr unverändert sind, nennt ihre Zahl als Filterlink und filtert auf // Wunsch. Die Nutzungsseite nennt die Zahl ebenfalls. func TestStaleEntries(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) cookie := login(t, h, createKey(t, srv, db, project.ID, "pflege", auth.RoleWrite)) twoYearsAgo := time.Now().Add(-2 * 365 * 24 * time.Hour) create(t, db, project, store.EntryWrite{Slug: "alt", Title: "Uralter Eintrag", Content: "x", CreatedAt: twoYearsAgo, UpdatedAt: twoYearsAgo}) create(t, db, project, store.EntryWrite{Slug: "neu", Title: "Frischer Eintrag", Content: "y"}) list := fetchPage(h, cookie, "/p/"+project.Slug+"/").Body.String() if !strings.Contains(list, ">1 veraltet") { t.Error("Filterlink „1 veraltet“ fehlt in der Liste") } if strings.Count(list, `class="badge warning"`) != 1 { t.Error("genau ein Eintrag sollte als veraltet markiert sein") } filtered := fetchPage(h, cookie, "/p/"+project.Slug+"/?veraltet=1").Body.String() if !strings.Contains(filtered, "Uralter Eintrag") || strings.Contains(filtered, "Frischer Eintrag") { t.Error("Filter veraltet zeigt nicht genau den alten Eintrag") } usage := fetchPage(h, cookie, "/p/"+project.Slug+"/usage").Body.String() if !strings.Contains(usage, `href="/p/`+project.Slug+`/?veraltet=1">1`) { t.Error("Nutzungsseite nennt die Zahl der veralteten Einträge nicht") } } // TestAlsoRelated: Die Eintragsansicht zeigt die zweite Reihe im // Verweisgraphen samt Zwischeneintrag — a verweist auf b, b auf c, also ist // c über b mit a verwandt. func TestAlsoRelated(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) cookie := login(t, h, createKey(t, srv, db, project.ID, "lesen", auth.RoleRead)) create(t, db, project, store.EntryWrite{Slug: "a", Title: "Eintrag A", Content: "siehe [[b]]"}) create(t, db, project, store.EntryWrite{Slug: "b", Title: "Eintrag B", Content: "siehe [[c]]"}) create(t, db, project, store.EntryWrite{Slug: "c", Title: "Eintrag C", Content: "Ende"}) page := fetchPage(h, cookie, "/p/"+project.Slug+"/e/a").Body.String() _, box, found := strings.Cut(page, "Auch verwandt") if !found { t.Fatal("Kasten „Auch verwandt“ fehlt") } if !strings.Contains(box, "Eintrag C") || !strings.Contains(box, `über b`) { t.Errorf("C über B fehlt im Kasten") } // Ohne zweite Reihe kein Kasten: c verweist auf nichts, b auf c, a auf b // — von b aus sind a und c direkte Nachbarn. if strings.Contains(fetchPage(h, cookie, "/p/"+project.Slug+"/e/b").Body.String(), "Auch verwandt") { t.Error("Kasten erscheint, obwohl es keine zweite Reihe gibt") } } // TestSimilarEntries: Das Formular fragt beim Tippen nach ähnlichen // Einträgen — dieselbe Prüfung wie "remember" über MCP. func TestSimilarEntries(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) write := login(t, h, createKey(t, srv, db, project.ID, "pflege", auth.RoleWrite)) read := login(t, h, createKey(t, srv, db, project.ID, "lesen", auth.RoleRead)) create(t, db, project, store.EntryWrite{Slug: "deployment-prozess", Title: "Deployment-Prozess", Content: "Pipeline"}) question := func(cookie *http.Cookie, title, slug string) (int, []similarItem) { w := fetchPage(h, cookie, "/p/"+project.Slug+"/entries/similar?title="+title+"&slug="+slug) var answer []similarItem if w.Code == http.StatusOK { if err := json.Unmarshal(w.Body.Bytes(), &answer); err != nil { t.Fatalf("keine JSON-Liste: %v (%s)", err, w.Body.String()) } } return w.Code, answer } code, answer := question(write, "Deployment-Ablauf", "") if code != http.StatusOK || len(answer) != 1 || answer[0].Slug != "deployment-prozess" { t.Fatalf("Antwort = %d %+v, erwartet deployment-prozess", code, answer) } if strings.Join(answer[0].Shared, ",") != "deployment" { t.Errorf("gemeinsame Wörter = %v, erwartet deployment", answer[0].Shared) } if _, answer := question(write, "VPN-Zugang", ""); len(answer) != 0 { t.Errorf("unähnlicher Titel liefert Kandidaten: %+v", answer) } // Der Bezeichner gibt es schon: Das wäre eine Aktualisierung, kein Hinweis. if _, answer := question(write, "Deployment-Ablauf", "deployment-prozess"); len(answer) != 0 { t.Errorf("bei vorhandenem Bezeichner kommen Kandidaten: %+v", answer) } // Leserechte reichen nicht — das Formular gibt es für sie auch nicht. if code, _ := question(read, "Deployment-Ablauf", ""); code == http.StatusOK { t.Error("Lesezugang bekommt ähnliche Einträge") } } // TestFormAndShortcuts: Das Bearbeitungsformular trägt die Attribute für // Rückfrage, Speichern per Tastatur und „Gibt es schon?"; die Kürzel n und e // erscheinen nur, wo geschrieben werden darf. func TestFormAndShortcuts(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) write := login(t, h, createKey(t, srv, db, project.ID, "pflege", auth.RoleWrite)) read := login(t, h, createKey(t, srv, db, project.ID, "lesen", auth.RoleRead)) create(t, db, project, store.EntryWrite{Slug: "x", Title: "X", Content: "x"}) newer := fetchPage(h, write, "/p/"+project.Slug+"/new").Body.String() for _, attr := range []string{"data-unsaved", "data-shortcut-save", "data-similar-url"} { if !strings.Contains(newer, attr) { t.Errorf("%s fehlt im Formular für neue Einträge", attr) } } // Beim Bearbeiten gibt es nichts „schon": kein Hinweis auf ähnliche. if strings.Contains(fetchPage(h, write, "/p/"+project.Slug+"/e/x/edit").Body.String(), "data-similar-url") { t.Error("„Gibt es schon?“ erscheint beim Bearbeiten") } if !strings.Contains(fetchPage(h, write, "/p/"+project.Slug+"/").Body.String(), `data-shortcut="n"`) { t.Error("Kürzel n fehlt mit Schreibrecht") } if strings.Contains(fetchPage(h, read, "/p/"+project.Slug+"/").Body.String(), `data-shortcut="n"`) { t.Error("Kürzel n erscheint ohne Schreibrecht") } if strings.Contains(fetchPage(h, read, "/p/"+project.Slug+"/e/x").Body.String(), `data-shortcut="e"`) { t.Error("Kürzel e erscheint ohne Schreibrecht") } for _, file := range []string{"unsaved.js", "shortcuts.js", "similar.js"} { if w := fetchPage(h, read, "/static/"+file); w.Code != http.StatusOK { t.Errorf("/static/%s = %d", file, w.Code) } } } // TestAuditFilterForm: Die Protokollseite filtert nach Vorgang und Zugang // und behält die Auswahl im Formular. func TestAuditFilterForm(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) cookie := login(t, h, createKey(t, srv, db, project.ID, "pflege", auth.RoleWrite)) ctx := context.Background() db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent-a", Action: store.ActionEntryWrite, EntrySlug: "geschrieben-von-a", Source: store.SourceMCP}) db.LogAudit(ctx, store.AuditEvent{ProjectID: project.ID, Actor: "agent-b", Action: store.ActionEntryDelete, EntrySlug: "geloescht-von-b", Source: store.SourceMCP}) page := fetchPage(h, cookie, "/p/"+project.Slug+"/audit?vorgang=entry_delete&wer=agent-b").Body.String() if !strings.Contains(page, "geloescht-von-b") || strings.Contains(page, "geschrieben-von-a") { t.Error("Filter zeigt nicht genau den Löschvorgang von agent-b") } if !strings.Contains(page, `