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) } } }