package web import ( "archive/zip" "bytes" "context" "io" "mime/multipart" "net/http" "net/http/httptest" "strings" "testing" "munin/internal/auth" "munin/internal/store" ) // importRequest baut einen Upload wie das Formular der Import-Seite. func importRequest(t *testing.T, cookie *http.Cookie, csrf string, files map[string]string, fields map[string]string) *http.Request { t.Helper() var archive bytes.Buffer zw := zip.NewWriter(&archive) for filePath, content := range files { f, _ := zw.Create(filePath) f.Write([]byte(content)) } zw.Close() var body bytes.Buffer mw := multipart.NewWriter(&body) mw.WriteField(auth.CSRFFieldName, csrf) for name, value := range fields { mw.WriteField(name, value) } part, _ := mw.CreateFormFile("archiv", "import.zip") part.Write(archive.Bytes()) mw.Close() r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/import", &body) r.Header.Set("Content-Type", mw.FormDataContentType()) r.AddCookie(cookie) return r } // adminWithToken meldet einen Admin-Key an und berechnet sein CSRF-Token so, // wie es das Template tut. func adminWithToken(t *testing.T, srv *Server, db *store.Store, project store.Project, h http.Handler) (*http.Cookie, string) { t.Helper() key := createKey(t, srv, db, project.ID, "verwalterin", auth.RoleAdmin) cookie := login(t, h, key) keys, _ := db.ListAPIKeys(context.Background(), project.ID) subject := auth.SubjectKeyPrefix + project.WorkspaceID + ":" + keys[0].ID return cookie, auth.NewSigner([]byte(testSecret)).CSRFToken(subject) } func TestImportPreviewThenApply(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) cookie, csrf := adminWithToken(t, srv, db, project, 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. search := httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil) search.AddCookie(cookie) h.ServeHTTP(httptest.NewRecorder(), search) files := map[string]string{ "doku/verkehr.md": "# Verkehr\n\nAm Zebrastreifen anhalten.", "doku/_index.md": "Übersicht", } // Vorschau: nichts geschrieben. w := httptest.NewRecorder() h.ServeHTTP(w, importRequest(t, cookie, csrf, files, 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, project.ID, "verkehr"); err == nil { t.Fatal("die Vorschau hat geschrieben") } // Übernehmen. w = httptest.NewRecorder() h.ServeHTTP(w, importRequest(t, cookie, csrf, files, 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, project.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) } search = httptest.NewRequest(http.MethodGet, "/p/testprojekt/?q=zebrastreifen", nil) search.AddCookie(cookie) w = httptest.NewRecorder() h.ServeHTTP(w, search) 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. events, err := db.ListAudit(ctx, store.AuditFilter{ProjectID: project.ID, Limit: 50}) if err != nil { t.Fatal(err) } count := 0 for _, ev := range events { if ev.Action == store.ActionEntryImport { count++ } } if count != 1 { t.Errorf("%d Import-Einträge im Protokoll, erwartet 1", count) } } func TestImportNeedsAdmin(t *testing.T) { srv, db, project := testServer(t) h := testHandler(t, srv) key := createKey(t, srv, db, project.ID, "schreiber", auth.RoleWrite) cookie := login(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, project := testServer(t) h := testHandler(t, srv) cookie, csrf := adminWithToken(t, srv, db, project, h) var body bytes.Buffer mw := multipart.NewWriter(&body) mw.WriteField(auth.CSRFFieldName, csrf) part, _ := mw.CreateFormFile("archiv", "kaputt.zip") part.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 read int64 inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { read, _ = io.Copy(io.Discard, r.Body) }) h := LimitBodies(inner, BodyLimits{Web: 100, API: 1000, Import: 5000}) cases := []struct { method, filePath string want 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 cases { read = 0 h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(f.method, f.filePath, strings.NewReader(strings.Repeat("x", 10000)))) if read != f.want { t.Errorf("%s %s: %d Bytes gelesen, erwartet %d", f.method, f.filePath, read, f.want) } } }