388 lines
16 KiB
Go
388 lines
16 KiB
Go
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</a>") {
|
||
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</a>`) {
|
||
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 <a href="/p/`+projekt.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(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, `<option value="agent-b" selected>`) ||
|
||
!strings.Contains(seite, `<option value="entry_delete" selected>`) {
|
||
t.Error("Auswahl bleibt im Formular nicht erhalten")
|
||
}
|
||
|
||
// Unsinn in der Adresse: ungefiltert statt Fehlerseite.
|
||
w := seiteHolen(h, cookie, "/p/"+projekt.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)
|
||
}
|
||
}
|
||
|
||
// TestSystemseite: nur für den Plattform-Admin; zeigt Speicher, Bereitschaft
|
||
// und die geöffneten Workspaces mit ihrem Speicherort.
|
||
func TestSystemseite(t *testing.T) {
|
||
srv, db, projekt := testServer(t)
|
||
h := testHandler(t, srv)
|
||
|
||
admin := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||
w := seiteHolen(h, admin, "/admin/system")
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("/admin/system = %d", w.Code)
|
||
}
|
||
seite := w.Body.String()
|
||
for _, erwartet := range []string{"SQLite", "bereit", "keins — jede SQLite-Datei", projekt.WorkspaceID + ".db", "Kontroll-Datenbank"} {
|
||
if !strings.Contains(seite, erwartet) {
|
||
t.Errorf("%q fehlt auf der Systemseite", erwartet)
|
||
}
|
||
}
|
||
|
||
// Ein Projektzugang kommt nicht dran.
|
||
key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin))
|
||
if code := seiteHolen(h, key, "/admin/system").Code; code == http.StatusOK {
|
||
t.Error("Projekt-Admin sieht die Systemseite")
|
||
}
|
||
}
|
||
|
||
// TestSystemseiteZustand: 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 TestSystemseiteZustand(t *testing.T) {
|
||
srv, db, projekt := testServer(t)
|
||
h := testHandler(t, srv)
|
||
ctx := context.Background()
|
||
|
||
if _, err := db.CreateAPIKeyWithExpiry(ctx, projekt.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: projekt.ID, Slug: "notiz", Title: "Notiz", Content: "x", Source: store.SourceHuman,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
db.LogAudit(ctx, store.AuditEvent{
|
||
ProjectID: projekt.ID, Actor: "test", Action: store.ActionEntryWrite, Source: store.SourceWeb,
|
||
})
|
||
|
||
admin := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||
seite := seiteHolen(h, admin, "/admin/system").Body.String()
|
||
|
||
for _, erwartet := range []string{
|
||
"Zustand",
|
||
"1 Key läuft in den nächsten 14 Tagen ab",
|
||
"Aktivität der letzten 30 Tage",
|
||
`<svg class="diagramm"`,
|
||
"1 Schreibvorgang (Agenten 0, Menschen 1)", // Tooltip des heutigen Tages
|
||
"Bestand je Workspace",
|
||
"Schema",
|
||
} {
|
||
if !strings.Contains(seite, erwartet) {
|
||
t.Errorf("%q fehlt auf der Systemseite", erwartet)
|
||
}
|
||
}
|
||
// Ohne Suchen bleibt das zweite Diagramm leer und sagt das.
|
||
if !strings.Contains(seite, "Keine im ganzen Zeitraum") {
|
||
t.Error("leeres Suchdiagramm ohne Hinweis")
|
||
}
|
||
}
|
||
|
||
// TestKeyMitAblaufdatum: 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 TestKeyMitAblaufdatum(t *testing.T) {
|
||
srv, db, projekt := testServer(t)
|
||
h := testHandler(t, srv)
|
||
admin := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "verwalter", auth.RoleAdmin))
|
||
|
||
// Ein Datum in der Vergangenheit wird abgelehnt. (Zuerst, weil
|
||
// formularSenden das CSRF-Token mit dem jüngsten Key des Projekts bildet
|
||
// — das muss noch der angemeldete sein.)
|
||
gestern := time.Now().AddDate(0, 0, -2).Format("2006-01-02")
|
||
w := formularSenden(t, h, db, projekt, admin, "/p/"+projekt.Slug+"/keys/create", url.Values{
|
||
"name": {"zu-spaet"}, "role": {"read"}, "expires": {gestern},
|
||
})
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("Ablauf in der Vergangenheit: %d, erwartet 400", w.Code)
|
||
}
|
||
|
||
morgen := time.Now().AddDate(0, 0, 1)
|
||
w = formularSenden(t, h, db, projekt, admin, "/p/"+projekt.Slug+"/keys/create", url.Values{
|
||
"name": {"praktikum"}, "role": {"read"}, "expires": {morgen.Format("2006-01-02")},
|
||
})
|
||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Key angelegt") {
|
||
t.Fatalf("Anlegen: %d", w.Code)
|
||
}
|
||
liste := seiteHolen(h, admin, "/p/"+projekt.Slug+"/keys").Body.String()
|
||
if !strings.Contains(liste, morgen.Format("02.01.2006")) || !strings.Contains(liste, "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.
|
||
klartext, _ := auth.GenerateKey()
|
||
kurz, err := db.CreateAPIKeyWithExpiry(context.Background(), projekt.ID, "kurz", auth.HashKey(klartext), "read",
|
||
time.Now().Add(1500*time.Millisecond))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := srv.control.IndexKey(context.Background(), kurz.Hash, projekt.WorkspaceID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sitzung := anmelden(t, h, klartext)
|
||
if code := seiteHolen(h, sitzung, "/p/"+projekt.Slug+"/").Code; code != http.StatusOK {
|
||
t.Fatalf("vor dem Ablauf: %d", code)
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
if code := seiteHolen(h, sitzung, "/p/"+projekt.Slug+"/").Code; code == http.StatusOK {
|
||
t.Error("Sitzung gilt nach dem Ablauf des Keys weiter")
|
||
}
|
||
}
|
||
|
||
// TestSucheInAllenProjekten: 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 TestSucheInAllenProjekten(t *testing.T) {
|
||
srv, db, projekt := testServer(t)
|
||
h := testHandler(t, srv)
|
||
ctx := context.Background()
|
||
|
||
zweites, err := db.CreateProject(ctx, projekt.WorkspaceID, "vertrieb", "Vertrieb", "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
legeAn(t, db, projekt, store.EntryWrite{Slug: "vpn", Title: "VPN-Zugang Technik", Content: "Zugang über das Firmen-VPN."})
|
||
legeAn(t, db, zweites, store.EntryWrite{Slug: "vpn-vertrieb", Title: "VPN unterwegs", Content: "VPN für Außendienst."})
|
||
legeAn(t, db, zweites, store.EntryWrite{Slug: "preise", Title: "Preisliste", Content: "Rabatte."})
|
||
|
||
pruefe := func(name string, seite string) {
|
||
t.Helper()
|
||
for _, erwartet := range []string{"VPN-Zugang Technik", "VPN unterwegs", ">Testprojekt</a>", ">Vertrieb</a>"} {
|
||
if !strings.Contains(seite, erwartet) {
|
||
t.Errorf("%s: %q fehlt", name, erwartet)
|
||
}
|
||
}
|
||
if strings.Contains(seite, "Preisliste") {
|
||
t.Errorf("%s: unpassender Eintrag „Preisliste“ in den Treffern", name)
|
||
}
|
||
}
|
||
|
||
plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||
pruefe("Plattform-Admin", seiteHolen(h, plattform, "/admin/search?q=vpn").Body.String())
|
||
|
||
wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||
pruefe("Workspace-Admin", wsAdmin.get(h, "/admin/search?q=vpn").Body.String())
|
||
|
||
key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin))
|
||
if w := seiteHolen(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")
|
||
}
|
||
}
|
||
|
||
// TestHilfeJeRolle: 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 TestHilfeJeRolle(t *testing.T) {
|
||
srv, db, projekt := testServer(t)
|
||
h := testHandler(t, srv)
|
||
|
||
key := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "admin-key", auth.RoleAdmin))
|
||
wsAdmin := anmeldenAlsWorkspaceAdmin(t, srv, h, projekt.WorkspaceID)
|
||
plattform := anmeldenAlsPlattformAdmin(t, h, projekt.WorkspaceID)
|
||
|
||
for _, f := range []struct {
|
||
name string
|
||
hole func(string) int
|
||
workspace, plattSei int
|
||
}{
|
||
{"Projektzugang", func(p string) int { return seiteHolen(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 seiteHolen(h, plattform, p).Code }, http.StatusOK, http.StatusOK},
|
||
} {
|
||
if code := f.hole("/hilfe/workspace-admin"); code != f.workspace {
|
||
t.Errorf("%s: /hilfe/workspace-admin = %d, erwartet %d", f.name, code, f.workspace)
|
||
}
|
||
if code := f.hole("/hilfe/plattform-admin"); code != f.plattSei {
|
||
t.Errorf("%s: /hilfe/plattform-admin = %d, erwartet %d", f.name, code, f.plattSei)
|
||
}
|
||
}
|
||
}
|