From 99f6236ff9182c4b744be5252005439d7d48d0f0 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Mon, 21 Sep 2026 23:25:09 +0800 Subject: [PATCH] test: lock down HTTP and WebDAV permission matrix --- .../internal/api/permission_matrix_test.go | 151 ++++++++++++++++++ .../api/webdav_permission_matrix_test.go | 115 +++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 chfs-ng/internal/api/permission_matrix_test.go create mode 100644 chfs-ng/internal/api/webdav_permission_matrix_test.go diff --git a/chfs-ng/internal/api/permission_matrix_test.go b/chfs-ng/internal/api/permission_matrix_test.go new file mode 100644 index 0000000..d516dbb --- /dev/null +++ b/chfs-ng/internal/api/permission_matrix_test.go @@ -0,0 +1,151 @@ +package api + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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) + } + }) + } +} diff --git a/chfs-ng/internal/api/webdav_permission_matrix_test.go b/chfs-ng/internal/api/webdav_permission_matrix_test.go new file mode 100644 index 0000000..0148bbb --- /dev/null +++ b/chfs-ng/internal/api/webdav_permission_matrix_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "chfs-ng/internal/auth" + "chfs-ng/internal/storage" +) + +func webDAVMatrixServer(t *testing.T, mask auth.PermMask) (*Server, string) { + t.Helper() + root := t.TempDir() + for _, name := range []string{"src-copy.txt", "src-move.txt", "src-delete.txt"} { + if err := os.WriteFile(filepath.Join(root, name), []byte("src"), 0o644); err != nil { + t.Fatal(err) + } + } + return &Server{ + Store: storage.Local{Root: root}, + Auth: &auth.Manager{ + Accounts: map[string]*auth.Account{}, + Guest: &auth.Account{Mask: mask}, + }, + RemoveMode: 3, + WebDAVRoot: root, + }, root +} + +func webDAVRequest(h http.Handler, method, target string, body []byte, headers map[string]string) int { + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + for k, v := range headers { + req.Header.Set(k, v) + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr.Code +} + +func TestWebDAVPermissionMatrix(t *testing.T) { + cases := []struct { + name string + mask auth.PermMask + read int + write int + del int + copy int + move int + }{ + {"none", auth.None, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden}, + {"read", auth.Read, http.StatusMultiStatus, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden}, + {"read-write", auth.Read | auth.Write, http.StatusMultiStatus, http.StatusCreated, http.StatusForbidden, http.StatusCreated, http.StatusCreated}, + {"read-write-delete", auth.Read | auth.Write | auth.Delete, http.StatusMultiStatus, http.StatusCreated, http.StatusNoContent, http.StatusCreated, http.StatusCreated}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s, root := webDAVMatrixServer(t, tc.mask) + h := s.Handler() + + propf := webDAVRequest(h, "PROPFIND", "/webdav/", nil, map[string]string{"Depth": "1"}) + if propf != tc.read { + t.Errorf("PROPFIND: got %d want %d", propf, tc.read) + } + get := webDAVRequest(h, http.MethodGet, "/webdav/src-copy.txt", nil, nil) + if get != func() int { + if tc.mask.HasRead() { + return http.StatusOK + } + return http.StatusForbidden + }() { + want := http.StatusForbidden + if tc.mask.HasRead() { + want = http.StatusOK + } + t.Errorf("GET: got %d want %d", get, want) + } + + put := webDAVRequest(h, http.MethodPut, "/webdav/new.txt", []byte("new"), nil) + if put != tc.write { + t.Errorf("PUT: got %d want %d", put, tc.write) + } + + mkcol := webDAVRequest(h, "MKCOL", "/webdav/newdir", nil, nil) + if mkcol != tc.write { + t.Errorf("MKCOL: got %d want %d", mkcol, tc.write) + } + + del := webDAVRequest(h, http.MethodDelete, "/webdav/src-delete.txt", nil, nil) + if del != tc.del { + t.Errorf("DELETE: got %d want %d", del, tc.del) + } + + copy := webDAVRequest(h, "COPY", "/webdav/src-copy.txt", nil, map[string]string{"Destination": "/webdav/copied.txt"}) + if copy != tc.copy { + t.Errorf("COPY: got %d want %d", copy, tc.copy) + } + + move := webDAVRequest(h, "MOVE", "/webdav/src-move.txt", nil, map[string]string{"Destination": "/webdav/moved.txt"}) + if move != tc.move { + t.Errorf("MOVE: got %d want %d", move, tc.move) + } + + if tc.write == http.StatusCreated { + if _, err := os.Stat(filepath.Join(root, "new.txt")); tc.name != "none" && err != nil { + t.Errorf("PUT file missing: %v", err) + } + } + }) + } +}