package api import ( "bytes" "io" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "chfs-ng/internal/auth" "chfs-ng/internal/storage" ) func permissionTestServer(t *testing.T, mask auth.PermMask) (*Server, string) { t.Helper() root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "dir"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "dir", "nested.txt"), []byte("nested"), 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: mask}, }, RemoveMode: 3, ImagePreview: true, } return s, root } func doRequest(h http.Handler, method, target string, body io.Reader, contentType string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, target, body) if contentType != "" { req.Header.Set("Content-Type", contentType) } rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } func multipartField(t *testing.T, field, value string) (*bytes.Buffer, string) { t.Helper() var b bytes.Buffer mw := multipart.NewWriter(&b) if err := mw.WriteField(field, value); err != nil { t.Fatal(err) } if err := mw.Close(); err != nil { t.Fatal(err) } return &b, mw.FormDataContentType() } func multipartUpload(t *testing.T, name, data string) (*bytes.Buffer, string) { t.Helper() var b bytes.Buffer mw := multipart.NewWriter(&b) fw, err := mw.CreateFormFile("file", name) if err != nil { t.Fatal(err) } if _, err := io.WriteString(fw, data); err != nil { t.Fatal(err) } if err := mw.WriteField("folder", ""); err != nil { t.Fatal(err) } if err := mw.Close(); err != nil { t.Fatal(err) } return &b, mw.FormDataContentType() } func TestHTTPPermissionMatrix(t *testing.T) { cases := []struct { name string mask auth.PermMask read int write int del int }{ {"none", auth.None, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden}, {"read", auth.Read, http.StatusOK, http.StatusForbidden, http.StatusForbidden}, {"read-write", auth.Read | auth.Write, http.StatusOK, http.StatusCreated, http.StatusForbidden}, {"read-write-delete", auth.Read | auth.Write | auth.Delete, http.StatusOK, http.StatusCreated, http.StatusNoContent}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s, root := permissionTestServer(t, tc.mask) h := s.Handler() files := doRequest(h, http.MethodGet, "/chfs/files?filepath=", nil, "") if files.Code != http.StatusOK { t.Fatalf("files: got %d", files.Code) } body, ct := multipartUpload(t, "uploaded.txt", "upload") upload := doRequest(h, http.MethodPost, "/chfs/upload", body, ct) if upload.Code != tc.write { t.Errorf("upload: got %d want %d", upload.Code, tc.write) } _, _ = os.Stat(filepath.Join(root, "uploaded.txt")) body, ct = multipartField(t, "filepath", "newdir") newdir := doRequest(h, http.MethodPost, "/chfs/newdir", body, ct) if newdir.Code != tc.write { t.Errorf("newdir: got %d want %d", newdir.Code, tc.write) } body, ct = multipartField(t, "old", "file.txt") body2, _ := multipartField(t, "new", "renamed.txt") var renameBody bytes.Buffer renameBody.Write(body.Bytes()) renameBody.Write(body2.Bytes()) // Rebuild the combined multipart request because each helper owns its boundary. var rb bytes.Buffer mw := multipart.NewWriter(&rb) _ = mw.WriteField("old", "file.txt") _ = mw.WriteField("new", "renamed.txt") _ = mw.Close() _ = body _ = ct rename := doRequest(h, http.MethodPost, "/chfs/rename", &rb, mw.FormDataContentType()) if rename.Code != tc.write { t.Errorf("rename: got %d want %d", rename.Code, tc.write) } deleteBody, deleteCT := multipartField(t, "filepath", "dir/nested.txt") del := doRequest(h, http.MethodDelete, "/chfs/rmfiles", deleteBody, deleteCT) if del.Code != tc.del { t.Errorf("delete: got %d want %d", del.Code, tc.del) } zip := doRequest(h, http.MethodGet, "/chfs/downloaddir/dir", nil, "") if zip.Code != tc.read { t.Errorf("zip: got %d want %d", zip.Code, tc.read) } }) } } func TestHTTPBatchDeleteAndTransferConflict(t *testing.T) { s, root := permissionTestServer(t, auth.Read|auth.Write|auth.Delete) h := s.Handler() if err := os.WriteFile(filepath.Join(root, "second.txt"), []byte("second"), 0o644); err != nil { t.Fatal(err) } var b bytes.Buffer mw := multipart.NewWriter(&b) _ = mw.WriteField("filepath", "file.txt") _ = mw.WriteField("filepath", "second.txt") _ = mw.Close() rr := doRequest(h, http.MethodDelete, "/chfs/rmfiles", &b, mw.FormDataContentType()) if rr.Code != http.StatusNoContent { t.Fatalf("batch delete: got %d", rr.Code) } if _, err := os.Stat(filepath.Join(root, "file.txt")); !os.IsNotExist(err) { t.Fatalf("file.txt still exists: %v", err) } if _, err := os.Stat(filepath.Join(root, "second.txt")); !os.IsNotExist(err) { t.Fatalf("second.txt still exists: %v", err) } if err := os.WriteFile(filepath.Join(root, "source.txt"), []byte("source"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil { t.Fatal(err) } form := url.Values{"source": {"source.txt"}, "destination": {"target.txt"}} copyReq := doRequest(h, http.MethodPost, "/chfs/copy", strings.NewReader(form.Encode()), "application/x-www-form-urlencoded") if copyReq.Code != http.StatusBadRequest { t.Fatalf("copy conflict: got %d", copyReq.Code) } data, err := os.ReadFile(filepath.Join(root, "target.txt")) if err != nil { t.Fatal(err) } if string(data) != "target" { t.Fatalf("target overwritten: %q", data) } }