package handler import ( "archive/zip" "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/listing" ) func writeTestZip(t *testing.T, path string, entries map[string]string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() zw := zip.NewWriter(f) for name, body := range entries { w, err := zw.Create(name) if err != nil { t.Fatalf("zip.Create(%q): %v", name, err) } if body != "" { if _, err := w.Write([]byte(body)); err != nil { t.Fatal(err) } } } if err := zw.Close(); err != nil { t.Fatal(err) } } func TestServeZip(t *testing.T) { root := t.TempDir() zipPath := filepath.Join(root, "P", "staging", "T.zip") writeTestZip(t, zipPath, map[string]string{ "DOC-001 (IFI) - Spec.pdf": "PDF-CONTENT", "sub/note.txt": "a note", "sub/deep/x.bin": "\x00\x01\x02", }) cfg := config.Config{Root: root} t.Run("root listing JSON", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/", nil) req.Header.Set("Accept", "application/json") rec := httptest.NewRecorder() ServeZip(cfg, rec, req, zipPath, "") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var fis []listing.FileInfo if err := json.Unmarshal(rec.Body.Bytes(), &fis); err != nil { t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) } byName := map[string]listing.FileInfo{} for _, fi := range fis { byName[fi.Name] = fi } if fi, ok := byName["DOC-001 (IFI) - Spec.pdf"]; !ok || fi.IsDir { t.Errorf("expected file entry; got %v", byName) } if fi, ok := byName["sub/"]; !ok || !fi.IsDir { t.Errorf("expected sub/ dir entry; got %v", byName) } // URL is relative to the request path and percent-escaped. if got := byName["DOC-001 (IFI) - Spec.pdf"].URL; got != "/P/staging/T.zip/DOC-001%20%28IFI%29%20-%20Spec.pdf" { t.Errorf("file URL=%q want escaped form", got) } if got := byName["sub/"].URL; got != "/P/staging/T.zip/sub/" { t.Errorf("dir URL=%q", got) } if rec.Header().Get("Vary") != "Accept" { t.Errorf("missing Vary: Accept") } if rec.Header().Get("ETag") == "" { t.Errorf("missing ETag") } }) t.Run("nested listing JSON", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub/", nil) req.Header.Set("Accept", "application/json") rec := httptest.NewRecorder() ServeZip(cfg, rec, req, zipPath, "sub") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var fis []listing.FileInfo json.Unmarshal(rec.Body.Bytes(), &fis) byName := map[string]bool{} for _, fi := range fis { byName[fi.Name] = fi.IsDir } if d, ok := byName["note.txt"]; !ok || d { t.Errorf("sub/ should contain file note.txt; got %v", byName) } if d, ok := byName["deep/"]; !ok || !d { t.Errorf("sub/ should contain dir deep/; got %v", byName) } }) t.Run("file member extracted", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub/note.txt", nil) rec := httptest.NewRecorder() ServeZip(cfg, rec, req, zipPath, "sub/note.txt") if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } if rec.Body.String() != "a note" { t.Errorf("body=%q", rec.Body.String()) } if rec.Header().Get("X-ZDDC-Source") != "zip:T.zip" { t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source")) } // http.ServeContent sets Content-Type from the name (.txt). if ct := rec.Header().Get("Content-Type"); ct == "" { t.Errorf("missing Content-Type") } }) t.Run("file member case-insensitive", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/SUB/NOTE.TXT", nil) rec := httptest.NewRecorder() ServeZip(cfg, rec, req, zipPath, "SUB/NOTE.TXT") if rec.Code != http.StatusOK || rec.Body.String() != "a note" { t.Errorf("status=%d body=%q", rec.Code, rec.Body.String()) } }) t.Run("range request on a member", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/DOC-001%20%28IFI%29%20-%20Spec.pdf", nil) req.Header.Set("Range", "bytes=0-2") rec := httptest.NewRecorder() ServeZip(cfg, rec, req, zipPath, "DOC-001 (IFI) - Spec.pdf") if rec.Code != http.StatusPartialContent { t.Fatalf("status=%d, want 206; body=%q", rec.Code, rec.Body.String()) } if rec.Body.String() != "PDF" { t.Errorf("partial body=%q, want PDF", rec.Body.String()) } }) t.Run("missing member 404", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/no/such.txt", nil) rec := httptest.NewRecorder() ServeZip(cfg, rec, req, zipPath, "no/such.txt") if rec.Code != http.StatusNotFound { t.Errorf("status=%d, want 404", rec.Code) } }) t.Run("directory member without trailing slash 302s", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/sub", nil) rec := httptest.NewRecorder() ServeZip(cfg, rec, req, zipPath, "sub") if rec.Code != http.StatusFound { t.Fatalf("status=%d, want 302", rec.Code) } if loc := rec.Header().Get("Location"); loc != "/P/staging/T.zip/sub/" { t.Errorf("Location=%q", loc) } }) t.Run("bad zip path", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/Nope.zip/", nil) rec := httptest.NewRecorder() ServeZip(cfg, rec, req, filepath.Join(root, "P", "staging", "Nope.zip"), "") if rec.Code != http.StatusNotFound { t.Errorf("status=%d, want 404", rec.Code) } }) t.Run("zip-slip member is unreachable", func(t *testing.T) { // Build a zip with a malicious entry; the handler must not surface it. evilZip := filepath.Join(root, "P", "staging", "Evil.zip") writeTestZip(t, evilZip, map[string]string{"ok.txt": "fine"}) // Manually append nothing nasty via the safe writer (zip.Writer // rejects "../" names? no — it allows them). Re-create with one. f, _ := os.Create(evilZip) zw := zip.NewWriter(f) w1, _ := zw.Create("ok.txt") w1.Write([]byte("fine")) w2, _ := zw.Create("../escape.txt") w2.Write([]byte("pwned")) zw.Close() f.Close() req := httptest.NewRequest(http.MethodGet, "/P/staging/Evil.zip/x", nil) rec := httptest.NewRecorder() ServeZip(cfg, rec, req, evilZip, "../escape.txt") if rec.Code != http.StatusNotFound { t.Errorf("zip-slip member status=%d, want 404", rec.Code) } // ...but the safe entry is fine. req2 := httptest.NewRequest(http.MethodGet, "/P/staging/Evil.zip/ok.txt", nil) rec2 := httptest.NewRecorder() ServeZip(cfg, rec2, req2, evilZip, "ok.txt") if rec2.Code != http.StatusOK || rec2.Body.String() != "fine" { t.Errorf("safe member status=%d body=%q", rec2.Code, rec2.Body.String()) } }) t.Run("HTML request serves something usable", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/P/staging/T.zip/", nil) req.Header.Set("Accept", "text/html") rec := httptest.NewRecorder() ServeZip(cfg, rec, req, zipPath, "") if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } ct := rec.Header().Get("Content-Type") if ct != "text/html; charset=utf-8" && ct != "application/json" { t.Errorf("Content-Type=%q, want html or json fallback", ct) } }) }