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.
// legeAn schreibt einen Eintrag direkt in den Store.
func legeAn(t *testing.T, db *store.Store, projekt store.Project, w store.EntryWrite) {
t.Helper()
w.ProjectID = projekt.ID
if w.Source == "" {
w.Source = store.SourceHuman
}
if _, err := db.PutEntry(context.Background(), w); err != nil {
t.Fatal(err)
}
}
// TestVeralteteEintraege: 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 TestVeralteteEintraege(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite))
vor2Jahren := time.Now().Add(-2 * 365 * 24 * time.Hour)
legeAn(t, db, projekt, store.EntryWrite{Slug: "alt", Title: "Uralter Eintrag", Content: "x",
CreatedAt: vor2Jahren, UpdatedAt: vor2Jahren})
legeAn(t, db, projekt, store.EntryWrite{Slug: "neu", Title: "Frischer Eintrag", Content: "y"})
liste := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/").Body.String()
if !strings.Contains(liste, ">1 veraltet") {
t.Error("Filterlink „1 veraltet“ fehlt in der Liste")
}
if strings.Count(liste, `class="marke warnung"`) != 1 {
t.Error("genau ein Eintrag sollte als veraltet markiert sein")
}
gefiltert := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/?veraltet=1").Body.String()
if !strings.Contains(gefiltert, "Uralter Eintrag") || strings.Contains(gefiltert, "Frischer Eintrag") {
t.Error("Filter veraltet zeigt nicht genau den alten Eintrag")
}
nutzung := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/usage").Body.String()
if !strings.Contains(nutzung, `href="/p/`+projekt.Slug+`/?veraltet=1">1`) {
t.Error("Nutzungsseite nennt die Zahl der veralteten Einträge nicht")
}
}
// TestAuchVerwandt: 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 TestAuchVerwandt(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead))
legeAn(t, db, projekt, store.EntryWrite{Slug: "a", Title: "Eintrag A", Content: "siehe [[b]]"})
legeAn(t, db, projekt, store.EntryWrite{Slug: "b", Title: "Eintrag B", Content: "siehe [[c]]"})
legeAn(t, db, projekt, store.EntryWrite{Slug: "c", Title: "Eintrag C", Content: "Ende"})
seite := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/e/a").Body.String()
_, kasten, gefunden := strings.Cut(seite, "Auch verwandt")
if !gefunden {
t.Fatal("Kasten „Auch verwandt“ fehlt")
}
if !strings.Contains(kasten, "Eintrag C") || !strings.Contains(kasten, `ü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(seiteHolen(h, cookie, "/p/"+projekt.Slug+"/e/b").Body.String(), "Auch verwandt") {
t.Error("Kasten erscheint, obwohl es keine zweite Reihe gibt")
}
}
// TestAehnlicheEintraege: Das Formular fragt beim Tippen nach ähnlichen
// Einträgen — dieselbe Prüfung wie "remember" über MCP.
func TestAehnlicheEintraege(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
schreiben := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite))
lesen := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead))
legeAn(t, db, projekt, store.EntryWrite{Slug: "deployment-prozess", Title: "Deployment-Prozess", Content: "Pipeline"})
frage := func(cookie *http.Cookie, titel, slug string) (int, []aehnlichItem) {
w := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/entries/similar?title="+titel+"&slug="+slug)
var antwort []aehnlichItem
if w.Code == http.StatusOK {
if err := json.Unmarshal(w.Body.Bytes(), &antwort); err != nil {
t.Fatalf("keine JSON-Liste: %v (%s)", err, w.Body.String())
}
}
return w.Code, antwort
}
code, antwort := frage(schreiben, "Deployment-Ablauf", "")
if code != http.StatusOK || len(antwort) != 1 || antwort[0].Slug != "deployment-prozess" {
t.Fatalf("Antwort = %d %+v, erwartet deployment-prozess", code, antwort)
}
if strings.Join(antwort[0].Gemeinsam, ",") != "deployment" {
t.Errorf("gemeinsame Wörter = %v, erwartet deployment", antwort[0].Gemeinsam)
}
if _, antwort := frage(schreiben, "VPN-Zugang", ""); len(antwort) != 0 {
t.Errorf("unähnlicher Titel liefert Kandidaten: %+v", antwort)
}
// Der Bezeichner gibt es schon: Das wäre eine Aktualisierung, kein Hinweis.
if _, antwort := frage(schreiben, "Deployment-Ablauf", "deployment-prozess"); len(antwort) != 0 {
t.Errorf("bei vorhandenem Bezeichner kommen Kandidaten: %+v", antwort)
}
// Leserechte reichen nicht — das Formular gibt es für sie auch nicht.
if code, _ := frage(lesen, "Deployment-Ablauf", ""); code == http.StatusOK {
t.Error("Lesezugang bekommt ähnliche Einträge")
}
}
// TestFormularUndKuerzel: 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 TestFormularUndKuerzel(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
schreiben := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite))
lesen := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "lesen", auth.RoleRead))
legeAn(t, db, projekt, store.EntryWrite{Slug: "x", Title: "X", Content: "x"})
neu := seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/new").Body.String()
for _, attr := range []string{"data-ungespeichert", "data-kuerzel-speichern", "data-aehnlich-url"} {
if !strings.Contains(neu, 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(seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/e/x/edit").Body.String(), "data-aehnlich-url") {
t.Error("„Gibt es schon?“ erscheint beim Bearbeiten")
}
if !strings.Contains(seiteHolen(h, schreiben, "/p/"+projekt.Slug+"/").Body.String(), `data-kuerzel="n"`) {
t.Error("Kürzel n fehlt mit Schreibrecht")
}
if strings.Contains(seiteHolen(h, lesen, "/p/"+projekt.Slug+"/").Body.String(), `data-kuerzel="n"`) {
t.Error("Kürzel n erscheint ohne Schreibrecht")
}
if strings.Contains(seiteHolen(h, lesen, "/p/"+projekt.Slug+"/e/x").Body.String(), `data-kuerzel="e"`) {
t.Error("Kürzel e erscheint ohne Schreibrecht")
}
for _, datei := range []string{"ungespeichert.js", "tastenkuerzel.js", "aehnlich.js"} {
if w := seiteHolen(h, lesen, "/static/"+datei); w.Code != http.StatusOK {
t.Errorf("/static/%s = %d", datei, w.Code)
}
}
}
// TestProtokollFiltern: Die Protokollseite filtert nach Vorgang und Zugang
// und behält die Auswahl im Formular.
func TestProtokollFiltern(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "pflege", auth.RoleWrite))
ctx := context.Background()
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-a", Action: store.ActionEntryWrite,
EntrySlug: "geschrieben-von-a", Source: store.SourceMCP})
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-b", Action: store.ActionEntryDelete,
EntrySlug: "geloescht-von-b", Source: store.SourceMCP})
seite := seiteHolen(h, cookie, "/p/"+projekt.Slug+"/audit?vorgang=entry_delete&wer=agent-b").Body.String()
if !strings.Contains(seite, "geloescht-von-b") || strings.Contains(seite, "geschrieben-von-a") {
t.Error("Filter zeigt nicht genau den Löschvorgang von agent-b")
}
if !strings.Contains(seite, `