munin/internal/web/import_test.go
2026-10-19 10:13:56 +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"
)
// 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)
}
}
}