munin/internal/web/ui_test.go
2026-10-19 10:13:56 +02:00

388 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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</a>") {
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</a>`) {
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 <a href="/p/`+project.Slug+`/e/b">b</a>`) {
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, `<option value="agent-b" selected>`) ||
!strings.Contains(page, `<option value="entry_delete" selected>`) {
t.Error("Auswahl bleibt im Formular nicht erhalten")
}
// Unsinn in der Adresse: ungefiltert statt Fehlerseite.
w := fetchPage(h, cookie, "/p/"+project.Slug+"/audit?vorgang=gibts-nicht&von=gestern")
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "geschrieben-von-a") {
t.Errorf("kaputter Filter: %d, erwartet das ungefilterte Protokoll", w.Code)
}
}
// TestSystemPage: nur für den Plattform-Admin; zeigt Speicher, Bereitschaft
// und die geöffneten Workspaces mit ihrem Speicherort.
func TestSystemPage(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
admin := loginAsPlatformAdmin(t, h, project.WorkspaceID)
w := fetchPage(h, admin, "/admin/system")
if w.Code != http.StatusOK {
t.Fatalf("/admin/system = %d", w.Code)
}
page := w.Body.String()
for _, want := range []string{"SQLite", "bereit", "keins — jede SQLite-Datei", project.WorkspaceID + ".db", "Kontroll-Datenbank"} {
if !strings.Contains(page, want) {
t.Errorf("%q fehlt auf der Systemseite", want)
}
}
// Ein Projektzugang kommt nicht dran.
key := login(t, h, createKey(t, srv, db, project.ID, "admin-key", auth.RoleAdmin))
if code := fetchPage(h, key, "/admin/system").Code; code == http.StatusOK {
t.Error("Projekt-Admin sieht die Systemseite")
}
}
// TestSystemPageState: Zustand, Aktivität und Bestand (AUSBAUSTUFEN.md
// 5.16). Ein bald ablaufender Key erscheint als Hinweis, ein geschriebener
// Eintrag im Diagramm und in der Bestandstabelle.
func TestSystemPageState(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
if _, err := db.CreateAPIKeyWithExpiry(ctx, project.ID, "praktikum", "hash-praktikum", string(auth.RoleRead),
time.Now().Add(5*24*time.Hour)); err != nil {
t.Fatal(err)
}
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
db.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, Actor: "test", Action: store.ActionEntryWrite, Source: store.SourceWeb,
})
admin := loginAsPlatformAdmin(t, h, project.WorkspaceID)
page := fetchPage(h, admin, "/admin/system").Body.String()
for _, want := range []string{
"Zustand",
"1 Key läuft in den nächsten 14 Tagen ab",
"Aktivität der letzten 30 Tage",
`<svg class="chart"`,
"1 Schreibvorgang (Agenten 0, Menschen 1)", // Tooltip des heutigen Tages
"Bestand je Workspace",
"Schema",
} {
if !strings.Contains(page, want) {
t.Errorf("%q fehlt auf der Systemseite", want)
}
}
// Ohne Suchen bleibt das zweite Diagramm leer und sagt das.
if !strings.Contains(page, "Keine im ganzen Zeitraum") {
t.Error("leeres Suchdiagramm ohne Hinweis")
}
}
// TestKeyWithExpiryDate: Ein Key lässt sich mit "gültig bis" anlegen, die
// Liste zeigt das Datum und markiert ihn als bald ablaufend. Läuft er ab,
// endet auch eine bestehende Sitzung.
func TestKeyWithExpiryDate(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
admin := login(t, h, createKey(t, srv, db, project.ID, "verwalter", auth.RoleAdmin))
// Ein Datum in der Vergangenheit wird abgelehnt. (Zuerst, weil
// submitForm das CSRF-Token mit dem jüngsten Key des Projekts bildet
// — das muss noch der angemeldete sein.)
yesterday := time.Now().AddDate(0, 0, -2).Format("2006-01-02")
w := submitForm(t, h, db, project, admin, "/p/"+project.Slug+"/keys/create", url.Values{
"name": {"zu-spaet"}, "role": {"read"}, "expires": {yesterday},
})
if w.Code != http.StatusBadRequest {
t.Errorf("Ablauf in der Vergangenheit: %d, erwartet 400", w.Code)
}
tomorrow := time.Now().AddDate(0, 0, 1)
w = submitForm(t, h, db, project, admin, "/p/"+project.Slug+"/keys/create", url.Values{
"name": {"praktikum"}, "role": {"read"}, "expires": {tomorrow.Format("2006-01-02")},
})
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Key angelegt") {
t.Fatalf("Anlegen: %d", w.Code)
}
list := fetchPage(h, admin, "/p/"+project.Slug+"/keys").Body.String()
if !strings.Contains(list, tomorrow.Format("02.01.2006")) || !strings.Contains(list, "läuft bald ab") {
t.Error("Liste zeigt das Ablaufdatum oder „läuft bald ab“ nicht")
}
// Sitzung eines Keys, der gleich abläuft: danach kein Zugriff mehr.
plaintext, _ := auth.GenerateKey()
short, err := db.CreateAPIKeyWithExpiry(context.Background(), project.ID, "kurz", auth.HashKey(plaintext), "read",
time.Now().Add(1500*time.Millisecond))
if err != nil {
t.Fatal(err)
}
if err := srv.control.IndexKey(context.Background(), short.Hash, project.WorkspaceID); err != nil {
t.Fatal(err)
}
session := login(t, h, plaintext)
if code := fetchPage(h, session, "/p/"+project.Slug+"/").Code; code != http.StatusOK {
t.Fatalf("vor dem Ablauf: %d", code)
}
time.Sleep(2 * time.Second)
if code := fetchPage(h, session, "/p/"+project.Slug+"/").Code; code == http.StatusOK {
t.Error("Sitzung gilt nach dem Ablauf des Keys weiter")
}
}
// TestSearchAllProjects: Workspace- und Plattform-Admin finden Einträge
// aus allen Projekten des Workspace, nach Projekt gruppiert. Ein
// Projektzugang kommt nicht dran — er sieht weiter nur sein Projekt.
func TestSearchAllProjects(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
second, err := db.CreateProject(ctx, project.WorkspaceID, "vertrieb", "Vertrieb", "")
if err != nil {
t.Fatal(err)
}
create(t, db, project, store.EntryWrite{Slug: "vpn", Title: "VPN-Zugang Technik", Content: "Zugang über das Firmen-VPN."})
create(t, db, second, store.EntryWrite{Slug: "vpn-vertrieb", Title: "VPN unterwegs", Content: "VPN für Außendienst."})
create(t, db, second, store.EntryWrite{Slug: "preise", Title: "Preisliste", Content: "Rabatte."})
check := func(name string, page string) {
t.Helper()
for _, want := range []string{"VPN-Zugang Technik", "VPN unterwegs", ">Testprojekt</a>", ">Vertrieb</a>"} {
if !strings.Contains(page, want) {
t.Errorf("%s: %q fehlt", name, want)
}
}
if strings.Contains(page, "Preisliste") {
t.Errorf("%s: unpassender Eintrag „Preisliste“ in den Treffern", name)
}
}
platform := loginAsPlatformAdmin(t, h, project.WorkspaceID)
check("Plattform-Admin", fetchPage(h, platform, "/admin/search?q=vpn").Body.String())
wsAdmin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID)
check("Workspace-Admin", wsAdmin.get(h, "/admin/search?q=vpn").Body.String())
key := login(t, h, createKey(t, srv, db, project.ID, "admin-key", auth.RoleAdmin))
if w := fetchPage(h, key, "/admin/search?q=vpn"); w.Code == http.StatusOK && strings.Contains(w.Body.String(), "VPN unterwegs") {
t.Error("Projektzugang findet Einträge aus einem anderen Projekt")
}
}
// TestHelpPerRole: Die Seite für Workspace-Admins sehen Workspace- und
// Plattform-Admin, die für Plattform-Admins nur dieser. Ein Projektzugang
// sieht keine von beiden — auch nicht über die Adresse.
func TestHelpPerRole(t *testing.T) {
srv, db, project := testServer(t)
h := testHandler(t, srv)
key := login(t, h, createKey(t, srv, db, project.ID, "admin-key", auth.RoleAdmin))
wsAdmin := loginAsWorkspaceAdmin(t, srv, h, project.WorkspaceID)
platform := loginAsPlatformAdmin(t, h, project.WorkspaceID)
for _, f := range []struct {
name string
fetch func(string) int
workspace, platformPage int
}{
{"Projektzugang", func(p string) int { return fetchPage(h, key, p).Code }, http.StatusNotFound, http.StatusNotFound},
{"Workspace-Admin", func(p string) int { return wsAdmin.get(h, p).Code }, http.StatusOK, http.StatusNotFound},
{"Plattform-Admin", func(p string) int { return fetchPage(h, platform, p).Code }, http.StatusOK, http.StatusOK},
} {
if code := f.fetch("/hilfe/workspace-admin"); code != f.workspace {
t.Errorf("%s: /hilfe/workspace-admin = %d, erwartet %d", f.name, code, f.workspace)
}
if code := f.fetch("/hilfe/plattform-admin"); code != f.platformPage {
t.Errorf("%s: /hilfe/plattform-admin = %d, erwartet %d", f.name, code, f.platformPage)
}
}
}