ZDDC/zddc/internal/handler/subtreezip_test.go
2026-06-11 13:32:31 -05:00

210 lines
6.8 KiB
Go

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", "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", "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", "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", "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", "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", "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
}