package handler import ( "archive/zip" "bytes" "context" "io" "net/http" "net/http/httptest" "os" "path/filepath" "sort" "testing" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" ) func mkfile(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } // readZipResponse decodes the zip body of a recorded response into // name → bytes. func readZipResponse(t *testing.T, rec *httptest.ResponseRecorder) map[string][]byte { t.Helper() body := rec.Body.Bytes() if len(body) == 0 { return map[string][]byte{} } zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) if err != nil { t.Fatalf("response body is not a valid zip: %v (%d bytes)", err, len(body)) } out := map[string][]byte{} for _, f := range zr.File { rc, err := f.Open() if err != nil { t.Fatalf("open zip entry %q: %v", f.Name, err) } b, _ := io.ReadAll(rc) rc.Close() out[f.Name] = b } return out } func subtreeReq(t *testing.T, method, urlPath, email string) *http.Request { t.Helper() req := httptest.NewRequest(method, urlPath, nil) req = req.WithContext(context.WithValue(req.Context(), EmailKey, email)) return req } func TestServeSubtreeZip(t *testing.T) { root := t.TempDir() mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n") sub := filepath.Join(root, "Proj", "sub") mkfile(t, filepath.Join(sub, "a.txt"), "AAA") mkfile(t, filepath.Join(sub, "nested", "b.txt"), "BBB") mkfile(t, filepath.Join(sub, ".zddc"), "acl:\n permissions:\n \"*\": r\n") // hidden — must not appear mkfile(t, filepath.Join(sub, "_template", "t.txt"), "tmpl") // hidden dir — must not appear // A locked subdir only owner@x can read. mkfile(t, filepath.Join(sub, "secret", ".zddc"), "acl:\n inherit: false\n permissions:\n \"owner@x\": rwcda\n") mkfile(t, filepath.Join(sub, "secret", "s.txt"), "SECRET") // A .zip file inside the subtree — must appear as opaque bytes, not extracted. writeTestZip(t, filepath.Join(sub, "Pack.zip"), map[string]string{"inner.txt": "INNER"}) cfg := config.Config{Root: root} t.Run("headers + ACL-filtered contents", func(t *testing.T) { req := subtreeReq(t, http.MethodGet, "/Proj/sub/?zip=1", "bob@x") rec := httptest.NewRecorder() ServeSubtreeZip(cfg, rec, req, sub) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%d bytes", rec.Code, rec.Body.Len()) } if ct := rec.Header().Get("Content-Type"); ct != "application/zip" { t.Errorf("Content-Type=%q", ct) } if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="sub.zip"` { t.Errorf("Content-Disposition=%q", cd) } if src := rec.Header().Get("X-ZDDC-Source"); src != "subtree-zip" { t.Errorf("X-ZDDC-Source=%q", src) } if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { t.Errorf("Cache-Control=%q", cc) } got := readZipResponse(t, rec) var names []string for n := range got { names = append(names, n) } sort.Strings(names) want := []string{"sub/Pack.zip", "sub/a.txt", "sub/nested/b.txt"} if len(names) != len(want) { t.Fatalf("zip entries = %v, want %v", names, want) } for i := range want { if names[i] != want[i] { t.Fatalf("zip entries = %v, want %v", names, want) } } if string(got["sub/a.txt"]) != "AAA" { t.Errorf("sub/a.txt = %q", got["sub/a.txt"]) } // Pack.zip is opaque: its bytes are themselves a valid zip with inner.txt. inner, err := zip.NewReader(bytes.NewReader(got["sub/Pack.zip"]), int64(len(got["sub/Pack.zip"]))) if err != nil { t.Fatalf("sub/Pack.zip is not a zip: %v", err) } if len(inner.File) != 1 || inner.File[0].Name != "inner.txt" { t.Errorf("sub/Pack.zip should contain just inner.txt; got %d entries", len(inner.File)) } }) t.Run("owner sees the locked subdir too", func(t *testing.T) { req := subtreeReq(t, http.MethodGet, "/Proj/sub/?zip=1", "owner@x") rec := httptest.NewRecorder() ServeSubtreeZip(cfg, rec, req, sub) got := readZipResponse(t, rec) if _, ok := got["sub/secret/s.txt"]; !ok { t.Errorf("owner@x should see sub/secret/s.txt; got %v", keysOf(got)) } if string(got["sub/secret/s.txt"]) != "SECRET" { t.Errorf("sub/secret/s.txt = %q", got["sub/secret/s.txt"]) } }) t.Run("HEAD sets headers, no body", func(t *testing.T) { req := subtreeReq(t, http.MethodHead, "/Proj/sub/?zip=1", "bob@x") rec := httptest.NewRecorder() ServeSubtreeZip(cfg, rec, req, sub) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } if rec.Header().Get("Content-Type") != "application/zip" { t.Errorf("missing Content-Type on HEAD") } if rec.Body.Len() != 0 { t.Errorf("HEAD body should be empty; got %d bytes", rec.Body.Len()) } }) t.Run("method not allowed", func(t *testing.T) { req := subtreeReq(t, http.MethodPost, "/Proj/sub/?zip=1", "bob@x") rec := httptest.NewRecorder() ServeSubtreeZip(cfg, rec, req, sub) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("POST status=%d, want 405", rec.Code) } }) } func TestServeSubtreeZip_AllDenied(t *testing.T) { root := t.TempDir() mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n") locked := filepath.Join(root, "Proj", "locked") mkfile(t, filepath.Join(locked, ".zddc"), "acl:\n inherit: false\n permissions:\n \"owner@x\": rwcda\n") mkfile(t, filepath.Join(locked, "x.txt"), "x") mkfile(t, filepath.Join(locked, "deep", "y.txt"), "y") cfg := config.Config{Root: root} req := subtreeReq(t, http.MethodGet, "/Proj/locked/?zip=1", "bob@x") rec := httptest.NewRecorder() ServeSubtreeZip(cfg, rec, req, locked) if rec.Code != http.StatusOK { t.Fatalf("status=%d, want 200 (empty zip)", rec.Code) } got := readZipResponse(t, rec) if len(got) != 0 { t.Errorf("expected empty zip for a fully-denied subtree; got %v", keysOf(got)) } } func TestServeSubtreeZip_Nonexistent(t *testing.T) { root := t.TempDir() mkfile(t, filepath.Join(root, ".zddc"), "acl:\n permissions:\n \"*\": r\n") cfg := config.Config{Root: root} missing := filepath.Join(root, "Proj", "working") // declared by the cascade, not on disk req := subtreeReq(t, http.MethodGet, "/Proj/working/?zip=1", "bob@x") rec := httptest.NewRecorder() ServeSubtreeZip(cfg, rec, req, missing) if rec.Code != http.StatusOK { t.Fatalf("status=%d, want 200 (empty zip)", rec.Code) } if got := readZipResponse(t, rec); len(got) != 0 { t.Errorf("expected empty zip for a nonexistent dir; got %v", keysOf(got)) } if cd := rec.Header().Get("Content-Disposition"); cd != `attachment; filename="working.zip"` { t.Errorf("Content-Disposition=%q", cd) } } func keysOf(m map[string][]byte) []string { var ks []string for k := range m { ks = append(ks, k) } sort.Strings(ks) return ks }