- 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>
194 lines
6 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|