munin/internal/web/import_test.go
Thomas Adamek cf35c3f5db Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)
- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:47 +02:00

194 lines
6 KiB
Go

package web
import (
"archive/zip"
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"munin/internal/auth"
"munin/internal/store"
)
// importAnfrage baut einen Upload wie das Formular der Import-Seite.
func importAnfrage(t *testing.T, cookie *http.Cookie, csrf string, dateien map[string]string, felder map[string]string) *http.Request {
t.Helper()
var archiv bytes.Buffer
zw := zip.NewWriter(&archiv)
for pfad, inhalt := range dateien {
f, _ := zw.Create(pfad)
f.Write([]byte(inhalt))
}
zw.Close()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
mw.WriteField(auth.CSRFFieldName, csrf)
for name, wert := range felder {
mw.WriteField(name, wert)
}
teil, _ := mw.CreateFormFile("archiv", "import.zip")
teil.Write(archiv.Bytes())
mw.Close()
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body)
r.Header.Set("Content-Type", mw.FormDataContentType())
r.AddCookie(cookie)
return r
}
// adminMitToken meldet einen Admin-Key an und berechnet sein CSRF-Token so,
// wie es das Template tut.
func adminMitToken(t *testing.T, srv *Server, db *store.Store, projekt store.Project, h http.Handler) (*http.Cookie, string) {
t.Helper()
key := erzeugeKey(t, srv, db, projekt.ID, "verwalterin", auth.RoleAdmin)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
subject := auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keys[0].ID
return cookie, auth.NewSigner([]byte(testSecret)).CSRFToken(subject)
}
func TestImportPreviewThenApply(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie, csrf := adminMitToken(t, srv, db, projekt, h)
ctx := context.Background()
// Suchindex vorher aufbauen. Der Import muss ihn nachziehen, sonst fände
// die Suche die neuen Einträge erst nach einem Neustart.
suche := httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil)
suche.AddCookie(cookie)
h.ServeHTTP(httptest.NewRecorder(), suche)
dateien := map[string]string{
"doku/verkehr.md": "# Verkehr\n\nAm Zebrastreifen anhalten.",
"doku/_index.md": "Übersicht",
}
// Vorschau: nichts geschrieben.
w := httptest.NewRecorder()
h.ServeHTTP(w, importAnfrage(t, cookie, csrf, dateien, map[string]string{"vorschau": "1", "modus": "skip"}))
if w.Code != http.StatusOK {
t.Fatalf("Vorschau: Status %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "Vorschau") || !strings.Contains(w.Body.String(), "verkehr") {
t.Error("die Vorschau zeigt die Datei nicht")
}
if _, err := db.EntryBySlug(ctx, projekt.ID, "verkehr"); err == nil {
t.Fatal("die Vorschau hat geschrieben")
}
// Übernehmen.
w = httptest.NewRecorder()
h.ServeHTTP(w, importAnfrage(t, cookie, csrf, dateien, map[string]string{"modus": "skip"}))
if w.Code != http.StatusOK {
t.Fatalf("Import: Status %d: %s", w.Code, w.Body.String())
}
e, err := db.EntryBySlug(ctx, projekt.ID, "verkehr")
if err != nil {
t.Fatalf("Eintrag fehlt nach dem Import: %v", err)
}
if e.Author != "verwalterin" || e.Title != "Verkehr" {
t.Errorf("Autor %q, Titel %q", e.Author, e.Title)
}
suche = httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil)
suche.AddCookie(cookie)
w = httptest.NewRecorder()
h.ServeHTTP(w, suche)
if !strings.Contains(w.Body.String(), "/e/verkehr") {
t.Error("die Suche findet den importierten Eintrag nicht — Suchindex nicht nachgezogen")
}
// Genau ein Protokolleintrag für den ganzen Import.
ereignisse, err := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID, Limit: 50})
if err != nil {
t.Fatal(err)
}
anzahl := 0
for _, ev := range ereignisse {
if ev.Action == store.ActionEntryImport {
anzahl++
}
}
if anzahl != 1 {
t.Errorf("%d Import-Einträge im Protokoll, erwartet 1", anzahl)
}
}
func TestImportNeedsAdmin(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "schreiber", auth.RoleWrite)
cookie := anmelden(t, h, key)
r := httptest.NewRequest(http.MethodGet, "/p/testprojekt/import", nil)
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("Status = %d, erwartet %d", w.Code, http.StatusForbidden)
}
}
func TestImportRejectsNonZip(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
cookie, csrf := adminMitToken(t, srv, db, projekt, h)
var body bytes.Buffer
mw := multipart.NewWriter(&body)
mw.WriteField(auth.CSRFFieldName, csrf)
teil, _ := mw.CreateFormFile("archiv", "kaputt.zip")
teil.Write([]byte("das ist kein zip"))
mw.Close()
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body)
r.Header.Set("Content-Type", mw.FormDataContentType())
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "ZIP") {
t.Errorf("Status %d, Antwort ohne verständliche Meldung", w.Code)
}
}
// TestLimitBodiesPerRoute prüft, dass /mcp und der Import ihre eigene,
// größere Grenze bekommen und alles andere die kleine. Früher lag ein
// einziges Limit über allem und schnitt MCP auf 1 MB ab.
func TestLimitBodiesPerRoute(t *testing.T) {
var gelesen int64
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gelesen, _ = io.Copy(io.Discard, r.Body)
})
h := LimitBodies(inner, BodyLimits{Web: 100, API: 1000, Import: 5000})
faelle := []struct {
methode, pfad string
erwartet int64
}{
{http.MethodPost, "/p/x/save", 100},
{http.MethodPost, "/mcp", 1000},
{http.MethodPost, "/p/x/import", 5000},
{http.MethodGet, "/p/x/import", 100},
{http.MethodPost, "/p/x/e/import", 100}, // ein Eintrag namens "import", nicht die Import-Seite
}
for _, f := range faelle {
gelesen = 0
h.ServeHTTP(httptest.NewRecorder(),
httptest.NewRequest(f.methode, f.pfad, strings.NewReader(strings.Repeat("x", 10000))))
if gelesen != f.erwartet {
t.Errorf("%s %s: %d Bytes gelesen, erwartet %d", f.methode, f.pfad, gelesen, f.erwartet)
}
}
}