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