package api import ( "archive/zip" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "chfs-ng/internal/auth" "chfs-ng/internal/storage" ) func TestSharedRoutesMatchCHFS(t *testing.T) { root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}} r := httptest.NewRequest(http.MethodGet, "/chfs/shared?file=hello.txt", nil) w := httptest.NewRecorder() s.Handler().ServeHTTP(w, r) if w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "shared/?file=hello.txt" { t.Fatalf("shared redirect: status=%d location=%q", w.Code, w.Header().Get("Location")) } r = httptest.NewRequest(http.MethodGet, "/chfs/shared/hello.txt", nil) w = httptest.NewRecorder() s.Handler().ServeHTTP(w, r) if w.Code != http.StatusOK || w.Header().Get("Content-Disposition") != `attachment; filename=hello.txt` { t.Fatalf("shared file: status=%d disposition=%q", w.Code, w.Header().Get("Content-Disposition")) } body, _ := io.ReadAll(w.Result().Body) if string(body) != "hello" { t.Fatalf("shared body=%q", body) } } func TestSharedGuestFlowAndSafeURLs(t *testing.T) { root := t.TempDir() if err := os.Mkdir(filepath.Join(root, "docs"), 0o755); err != nil { t.Fatal(err) } if err := os.Mkdir(filepath.Join(root, "private"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "docs", "你好 #?.txt"), []byte("shared"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "private", "secret.txt"), []byte("secret"), 0o644); err != nil { t.Fatal(err) } s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{ Mask: auth.Read, Rules: []auth.PathRule{{Dir: "private", Deny: true}}, }}, } for _, tc := range []struct { name, url string want int }{ {"guest file", "/chfs/shared/docs/%E4%BD%A0%E5%A5%BD%20%23%3F.txt", http.StatusOK}, {"denied file", "/chfs/shared/private/secret.txt", http.StatusNotFound}, {"traversal", "/chfs/shared/../private/secret.txt", http.StatusNotFound}, } { t.Run(tc.name, func(t *testing.T) { r := httptest.NewRequest(http.MethodGet, tc.url, nil) w := httptest.NewRecorder() if tc.name == "traversal" { s.shared(w, r) } else { s.Handler().ServeHTTP(w, r) } if w.Code != tc.want { t.Fatalf("status=%d want=%d body=%q", w.Code, tc.want, w.Body.String()) } }) } r := httptest.NewRequest(http.MethodGet, "/chfs/shared/docs", nil) w := httptest.NewRecorder() s.Handler().ServeHTTP(w, r) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "你好 #?") { t.Fatalf("shared directory listing status=%d body=%q", w.Code, w.Body.String()) } if strings.Contains(w.Body.String(), "private") { t.Fatalf("denied entry leaked in listing: %q", w.Body.String()) } r = httptest.NewRequest(http.MethodHead, "/chfs/shared/docs/%E4%BD%A0%E5%A5%BD%20%23%3F.txt", nil) w = httptest.NewRecorder() s.Handler().ServeHTTP(w, r) if w.Code != http.StatusOK || w.Body.Len() != 0 { t.Fatalf("HEAD status=%d body=%d", w.Code, w.Body.Len()) } if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "attachment") { t.Fatalf("disposition=%q", got) } } func TestDownloadDirArchiveHeadersAndEntries(t *testing.T) { root := t.TempDir() dir := filepath.Join(root, "pack") if err := os.Mkdir(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abc"), 0o644); err != nil { t.Fatal(err) } s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}} r := httptest.NewRequest(http.MethodGet, "/chfs/downloaddir/pack", nil) w := httptest.NewRecorder() s.Handler().ServeHTTP(w, r) if w.Code != http.StatusOK { t.Fatalf("archive status=%d body=%s", w.Code, w.Body.String()) } if got := w.Header().Get("Content-Type"); got != "application/zip" { t.Fatalf("archive content type=%q", got) } if got := w.Header().Get("Content-Disposition"); got != "" { t.Fatalf("CHFS archive must not emit Content-Disposition, got %q", got) } zr, err := zip.NewReader(bytesReaderAt{r: w.Body.Bytes()}, int64(w.Body.Len())) if err != nil { t.Fatalf("open archive: %v", err) } if len(zr.File) != 1 || zr.File[0].Name != "a.txt" { t.Fatalf("archive entries=%v", archiveNames(zr.File)) } if zr.File[0].UncompressedSize64 != 3 { t.Fatalf("archive size=%d", zr.File[0].UncompressedSize64) } } type bytesReaderAt struct{ r []byte } func (b bytesReaderAt) ReadAt(p []byte, off int64) (int, error) { if off >= int64(len(b.r)) { return 0, io.EOF } n := copy(p, b.r[off:]) if n < len(p) { return n, io.EOF } return n, nil } func archiveNames(files []*zip.File) []string { n := make([]string, 0, len(files)) for _, f := range files { n = append(n, f.Name) } return n }