refactor: flatten repository structure to root and archive research notes

This commit is contained in:
OpenClaw Agent
2026-09-22 03:41:27 +08:00
parent ae0becffec
commit 1a7658b1f7
40 changed files with 0 additions and 3 deletions
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"archive/zip"
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
)
func TestFolderDownloadModes(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "parent", "child"), 0o755); err != nil {
t.Fatal(err)
}
for _, p := range []string{
filepath.Join(root, "parent", "a.txt"),
filepath.Join(root, "parent", "child", "b.txt"),
} {
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
mk := func(mode string) *Server {
return &Server{
Store: storage.Local{Root: root},
Auth: &auth.Manager{
Accounts: map[string]*auth.Account{},
Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete},
},
FolderDownload: mode,
}
}
for _, tc := range []struct {
mode, path string
want int
}{
{"disable", "parent", http.StatusForbidden},
{"leaf", "parent", http.StatusForbidden},
{"leaf", "parent/child", http.StatusOK},
{"enable", "parent", http.StatusOK},
} {
t.Run(tc.mode+"-"+tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/chfs/downloaddir/"+tc.path, nil)
w := httptest.NewRecorder()
mk(tc.mode).Handler().ServeHTTP(w, req)
if w.Code != tc.want {
t.Fatalf("status=%d want=%d body=%s", w.Code, tc.want, w.Body.String())
}
})
}
}
func TestFolderDownloadZipContainsOnlyReadableFiles(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "parent", "child"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "parent", "a.txt"), []byte("A"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "parent", "child", "b.txt"), []byte("B"), 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}}, FolderDownload: "enable"}
r := httptest.NewRequest(http.MethodGet, "/chfs/downloaddir/parent", nil)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status=%d", w.Code)
}
zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
if err != nil {
t.Fatalf("invalid zip: %v", err)
}
got := map[string]string{}
for _, f := range zr.File {
body, err := f.Open()
if err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(body)
body.Close()
if err != nil {
t.Fatal(err)
}
got[f.Name] = string(b)
}
want := map[string]string{"a.txt": "A", "child/b.txt": "B"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("zip entries=%#v want=%#v", got, want)
}
}
+24
View File
@@ -0,0 +1,24 @@
package api
import (
"encoding/json"
"net/http"
)
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
}
var Version = "dev"
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(healthResponse{Status: "ok", Version: Version})
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"os"
"path/filepath"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
)
func TestCheckDeleteTreeRejectsDeniedDescendant(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "public", "private"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "public", "private", "secret.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
store := storage.Local{Root: root}
account := &auth.Account{Mask: auth.Delete, Rules: []auth.PathRule{{Dir: "public/private", Mask: auth.None}}}
if err := checkDeleteTree(store, account, "public"); err == nil {
t.Fatal("expected denied descendant to block recursive deletion")
}
}
func TestUnsafePath(t *testing.T) {
cases := map[string]bool{
"../secret": true,
"a/../secret": true,
`..\\secret`: true,
"%2e%2e/secret": false,
"secret": false,
"a//b": false,
"": false,
}
for p, want := range cases {
if got := unsafePath(p); got != want {
t.Fatalf("unsafePath(%q)=%v, want %v", p, got, want)
}
}
}
+194
View File
@@ -0,0 +1,194 @@
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)
}
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
)
func TestRootAndAPIDoc(t *testing.T) {
s := &Server{Store: storage.Local{Root: t.TempDir()}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, HTMLTitle: "My Files", HTMLNotice: "`<strong>Hello</strong>`"}
h := s.Handler()
r := httptest.NewRecorder()
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/", nil))
if r.Code != 200 || !strings.Contains(r.Body.String(), "<title>My Files</title>") || !strings.Contains(r.Body.String(), "<strong>Hello</strong>") {
t.Fatalf("root: %d %s", r.Code, r.Body.String())
}
r = httptest.NewRecorder()
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/asset/api.html", nil))
if r.Code != 200 || !strings.Contains(r.Body.String(), "/chfs/files") {
t.Fatalf("api doc: %d %s", r.Code, r.Body.String())
}
}
func TestRootEscapesPlainNotice(t *testing.T) {
s := &Server{Store: storage.Local{Root: t.TempDir()}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, HTMLNotice: `<script>alert(1)</script>`}
r := httptest.NewRecorder()
s.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/", nil))
if strings.Contains(r.Body.String(), `<script>alert(1)</script>`) {
t.Fatal("plain notice was not escaped")
}
}
func TestHealthz(t *testing.T) {
s := &Server{}
old := Version
Version = "test"
defer func() { Version = old }()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("cache-control=%q", got)
}
if !strings.Contains(rr.Body.String(), `"status":"ok"`) || !strings.Contains(rr.Body.String(), `"version":"test"`) {
t.Fatalf("unexpected body=%s", rr.Body.String())
}
}
File diff suppressed because it is too large Load Diff
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"archive/zip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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 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
}
+156
View File
@@ -0,0 +1,156 @@
package api
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
)
func uploadCompatServer(t *testing.T) (*Server, string) {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "dir"), 0o755); err != nil {
t.Fatal(err)
}
return &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}, RemoveMode: 3}, root
}
func makeUploadRequest(t *testing.T, method, target string, fieldName, fieldValue, fileName, content string) (*httptest.ResponseRecorder, *http.Request) {
t.Helper()
var b bytes.Buffer
mw := multipart.NewWriter(&b)
if fieldName != "" {
if err := mw.WriteField(fieldName, fieldValue); err != nil {
t.Fatal(err)
}
}
if fileName != "" {
fw, err := mw.CreateFormFile("file", fileName)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, target, &b)
req.Header.Set("Content-Type", mw.FormDataContentType())
return httptest.NewRecorder(), req
}
func TestUploadNormalizesClientFilenamesInsideTargetFolder(t *testing.T) {
s, root := uploadCompatServer(t)
for _, tc := range []struct{ input, output string }{{"../escape1.txt", "escape1.txt"}, {"/absolute3.txt", "absolute3.txt"}} {
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload?folder=dir", "", "", tc.input, "x")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Errorf("upload %q: status=%d", tc.input, rr.Code)
}
if _, err := os.Stat(filepath.Join(root, tc.output)); !os.IsNotExist(err) {
t.Fatalf("filename escaped target folder: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "dir", tc.output)); err != nil {
t.Fatalf("normalized upload %s missing: %v", tc.output, err)
}
}
}
func TestUpload2UsesQueryFolderAndRejectsTraversal(t *testing.T) {
s, root := uploadCompatServer(t)
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder=dir", "", "", "nested.txt", "hello")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("upload2 status=%d body=%s", rr.Code, rr.Body.String())
}
got, err := os.ReadFile(filepath.Join(root, "dir", "nested.txt"))
if err != nil {
t.Fatal(err)
}
if string(got) != "hello" {
t.Fatalf("content=%q", got)
}
for _, folder := range []string{"../", "..%2F", "..\\\\"} {
rr, req = makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder="+folder, "", "", "bad.txt", "x")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("upload2 folder %q: status=%d", folder, rr.Code)
}
}
}
func TestUploadRejectsDuplicateWithoutOverwriting(t *testing.T) {
s, root := uploadCompatServer(t)
target := filepath.Join(root, "dir", "same.txt")
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload", "folder", "dir", "same.txt", "replacement")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rr.Code)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "original" {
t.Fatalf("duplicate upload overwrote file: %q", got)
}
}
func TestUpload2RejectsDuplicateWithoutOverwriting(t *testing.T) {
s, root := uploadCompatServer(t)
target := filepath.Join(root, "dir", "same.txt")
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder=dir", "", "", "same.txt", "replacement")
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status=%d", rr.Code)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "original" {
t.Fatalf("duplicate upload2 overwrote file: %q", got)
}
}
func TestUpload2BatchWritesAllFiles(t *testing.T) {
s, root := uploadCompatServer(t)
var b bytes.Buffer
mw := multipart.NewWriter(&b)
for _, name := range []string{"first.txt", "second.txt"} {
fw, err := mw.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
_, _ = fw.Write([]byte(name))
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/chfs/upload2?folder=dir", &b)
req.Header.Set("Content-Type", mw.FormDataContentType())
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
for _, name := range []string{"first.txt", "second.txt"} {
if _, err := os.Stat(filepath.Join(root, "dir", name)); err != nil {
t.Fatalf("%s missing: %v", name, err)
}
}
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
)
func TestUserManagementAndPrivateHome(t *testing.T) {
root := t.TempDir()
usersFile := filepath.Join(t.TempDir(), "users.json")
mgr := &auth.Manager{
Accounts: map[string]*auth.Account{
"admin": {Username: "admin", Password: "secret", Admin: true, Mask: auth.Read | auth.Write | auth.Delete},
},
Guest: &auth.Account{Mask: auth.None},
}
s := &Server{Store: storage.Local{Root: root}, Auth: mgr, UserStore: &auth.UserStore{Path: usersFile}, UserHome: "users"}
h := s.Handler()
req := httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=admin&pwd=secret"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("admin login: %d %s", rr.Code, rr.Body.String())
}
cookie := rr.Result().Cookies()[0]
body := []byte(`{"username":"alice","password":"alice-pass"}`)
req = httptest.NewRequest(http.MethodPost, "/chfs/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("create user: %d %s", rr.Code, rr.Body.String())
}
if _, err := os.Stat(filepath.Join(root, "users", "alice")); err != nil {
t.Fatalf("home not created: %v", err)
}
req = httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=alice&pwd=alice-pass"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("dynamic user login: %d %s", rr.Code, rr.Body.String())
}
token, err := auth.NewToken("alice", 30)
if err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=", nil)
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "currentMask") || !strings.Contains(rr.Body.String(), "\"currentMask\":\"\"") {
t.Fatalf("private root access: %d %s", rr.Code, rr.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=users/alice", nil)
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"RWD\"") {
t.Fatalf("private home access: %d %s", rr.Code, rr.Body.String())
}
// Admin can disable a managed user, which immediately invalidates token-based access.
updateBody, _ := json.Marshal(map[string]any{"username": "alice", "enabled": false})
req = httptest.NewRequest(http.MethodPut, "/chfs/users", bytes.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("disable user: %d %s", rr.Code, rr.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=alice&pwd=alice-pass"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code == http.StatusCreated {
t.Fatal("disabled user was allowed to login")
}
// Re-enable and grant read-only access to a shared directory.
rules := []map[string]any{
{"dir": "public", "mask": "r"},
{"dir": "public/private", "mask": "r", "deny": true, "childrenOnly": true},
}
updateBody, _ = json.Marshal(map[string]any{"username": "alice", "enabled": true, "rules": rules})
req = httptest.NewRequest(http.MethodPut, "/chfs/users", bytes.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(cookie)
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("update rules: %d %s", rr.Code, rr.Body.String())
}
if err := os.MkdirAll(filepath.Join(root, "public"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "public", "readme.txt"), []byte("ok"), 0644); err != nil {
t.Fatal(err)
}
if got := mgr.Accounts["alice"].MaskFor("users/alice"); got != auth.Read|auth.Write|auth.Delete {
t.Fatalf("home permission was not preserved: %v", got)
}
if got := mgr.Accounts["alice"].MaskFor("public/private/file.txt"); got != auth.None {
t.Fatalf("deny rule was not applied: %v", got)
}
token, err = auth.NewToken("alice", 30)
if err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=public", nil)
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"R\"") {
t.Fatalf("shared read rule: %d %s", rr.Code, rr.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/chfs/roots", nil)
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "我的空间") || !strings.Contains(rr.Body.String(), "public") {
t.Fatalf("accessible roots: %d %s", rr.Code, rr.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/chfs/qrcode?url=https%3A%2F%2Fexample.com", nil)
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || rr.Header().Get("Content-Type") != "image/png" || len(rr.Body.Bytes()) < 100 {
t.Fatalf("qrcode: %d %q %d", rr.Code, rr.Header().Get("Content-Type"), len(rr.Body.Bytes()))
}
if err := os.WriteFile(filepath.Join(root, "public", "second.txt"), []byte("two"), 0644); err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodGet, "/chfs/downloadzip?path=public%2Freadme.txt&path=public%2Fsecond.txt", nil)
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
rr = httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != http.StatusOK || rr.Header().Get("Content-Type") != "application/zip" || len(rr.Body.Bytes()) < 50 {
t.Fatalf("selected zip: %d %q %d", rr.Code, rr.Header().Get("Content-Type"), len(rr.Body.Bytes()))
}
var saved []map[string]any
data, err := os.ReadFile(usersFile)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(data, &saved); err != nil || len(saved) != 1 {
t.Fatalf("user persistence: %v %#v", err, saved)
}
}
File diff suppressed because one or more lines are too long
@@ -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)
}
}
})
}
}
+224
View File
@@ -0,0 +1,224 @@
package api
import (
"context"
"errors"
"io"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
"github.com/emersion/go-webdav"
)
func TestWebDAVAuthFSCheckTree(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "test", "private"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "test", "public.txt"), []byte("public"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "test", "private", "secret.txt"), []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
account := &auth.Account{Mask: auth.Read | auth.Write | auth.Delete, Rules: []auth.PathRule{
{Dir: "test", Mask: auth.Read | auth.Write | auth.Delete},
{Dir: "test/private", Mask: auth.None},
}}
fs := &webdavAuthFS{inner: &webdavStorageFS{store: storage.Local{Root: root}}, account: account}
if err := fs.checkTree(context.Background(), "/test", auth.Delete); err == nil {
t.Fatal("expected recursive delete permission check to reject inaccessible descendant")
}
if err := fs.checkTree(context.Background(), "/test/public.txt", auth.Delete); err != nil {
t.Fatalf("public file should be deletable: %v", err)
}
}
func TestWebDAVAuthFSOverwriteRequiresDelete(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "src.txt"), []byte("src"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("dst"), 0o644); err != nil {
t.Fatal(err)
}
account := &auth.Account{Mask: auth.Write}
fs := &webdavAuthFS{inner: &webdavStorageFS{store: storage.Local{Root: root}}, account: account}
if err := fs.authorizeOverwrite(context.Background(), "/dst.txt"); err == nil {
t.Fatal("expected overwrite authorization to require delete permission")
}
account.Mask = auth.Delete
if err := fs.authorizeOverwrite(context.Background(), "/dst.txt"); err != nil {
t.Fatalf("delete permission should authorize overwrite: %v", err)
}
}
func TestSameWebDAVDestinationHost(t *testing.T) {
req := httptest.NewRequest("MOVE", "/webdav/src.txt", nil)
req.Host = "example.test"
req.Header.Set("Destination", "http://other.test/webdav/dst.txt")
if sameWebDAVDestinationHost(req) {
t.Fatal("cross-host destination should be rejected")
}
req.Header.Set("Destination", "http://example.test/webdav/dst.txt")
if !sameWebDAVDestinationHost(req) {
t.Fatal("same-host absolute destination should be accepted")
}
req.Header.Set("Destination", "/webdav/dst.txt")
if !sameWebDAVDestinationHost(req) {
t.Fatal("relative destination should be accepted")
}
}
func TestWebDAVStorageFSRejectsDestinationInsideSource(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "src", "child"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "src", "file.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
fs := &webdavStorageFS{store: storage.Local{Root: root}}
if _, err := fs.Copy(context.Background(), "/src", "/src/child/copy", &webdav.CopyOptions{}); err == nil {
t.Fatal("copying a directory into its descendant should fail")
}
if _, err := fs.Move(context.Background(), "/src", "/src/child/moved", &webdav.MoveOptions{}); err == nil {
t.Fatal("moving a directory into its descendant should fail")
}
if _, err := fs.Copy(context.Background(), "/src", "/src", &webdav.CopyOptions{}); err == nil {
t.Fatal("copying a resource onto itself should fail")
}
}
func TestWebDAVAuthFSMoveMissingSourcePrecedence(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "existing.txt"), []byte("existing"), 0o644); err != nil {
t.Fatal(err)
}
account := &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}
fs := &webdavAuthFS{inner: &webdavStorageFS{store: storage.Local{Root: root}}, account: account}
_, err := fs.Move(context.Background(), "/missing.txt", "/new.txt", &webdav.MoveOptions{})
if err == nil || err.Error() != "403 Forbidden: source not found" {
t.Fatalf("missing source to free destination: got %v", err)
}
_, err = fs.Move(context.Background(), "/missing.txt", "/existing.txt", &webdav.MoveOptions{})
if err == nil || err.Error() != "412 Precondition Failed: destination exists" {
t.Fatalf("missing source to occupied destination: got %v", err)
}
}
func TestWebDAVStorageFSCopyPreservesExistingDestinationOnSourceReadFailure(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
store := storage.Local{Root: root}
if err := os.WriteFile(filepath.Join(root, "src.txt"), []byte("replacement"), 0o644); err != nil {
t.Fatal(err)
}
fs := &webdavStorageFS{store: store}
if _, err := fs.Copy(context.Background(), "/src.txt", "/dst.txt", &webdav.CopyOptions{}); err != nil {
t.Fatalf("copy: %v", err)
}
got, err := os.ReadFile(filepath.Join(root, "dst.txt"))
if err != nil {
t.Fatal(err)
}
if string(got) != "replacement" {
t.Fatalf("destination content=%q", got)
}
matches, err := filepath.Glob(filepath.Join(root, "dst.txt.chfs-copy-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary copy files leaked: %v", matches)
}
}
type failingReader struct{}
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("source read failed") }
type readCloser struct{ io.Reader }
func (r readCloser) Close() error { return nil }
func TestWebDAVStorageFSCopyFailureDoesNotCorruptDestination(t *testing.T) {
root := t.TempDir()
store := storage.Local{Root: root}
if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
if err := copyFileToDestination(store, failingReader{}, "dst.txt"); err == nil {
t.Fatal("expected source read failure")
}
got, err := os.ReadFile(filepath.Join(root, "dst.txt"))
if err != nil {
t.Fatal(err)
}
if string(got) != "original" {
t.Fatalf("destination was corrupted: %q", got)
}
matches, err := filepath.Glob(filepath.Join(root, "dst.txt.chfs-copy-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary copy files leaked: %v", matches)
}
}
func TestWebDAVStorageFSCreateFailureDoesNotCorruptDestination(t *testing.T) {
root := t.TempDir()
store := storage.Local{Root: root}
if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("original"), 0o644); err != nil {
t.Fatal(err)
}
fs := &webdavStorageFS{store: store}
_, _, err := fs.Create(context.Background(), "/dst.txt", readCloser{Reader: failingReader{}}, nil)
if err == nil {
t.Fatal("expected PUT body failure")
}
got, err := os.ReadFile(filepath.Join(root, "dst.txt"))
if err != nil {
t.Fatal(err)
}
if string(got) != "original" {
t.Fatalf("destination was corrupted: %q", got)
}
matches, err := filepath.Glob(filepath.Join(root, "dst.txt.chfs-copy-*"))
if err != nil {
t.Fatal(err)
}
if len(matches) != 0 {
t.Fatalf("temporary PUT files leaked: %v", matches)
}
}
func TestWebDAVStorageFSCopyAndMove(t *testing.T) {
root1 := t.TempDir()
root2 := t.TempDir()
if err := os.WriteFile(filepath.Join(root1, "a.txt"), []byte("one"), 0o644); err != nil {
t.Fatal(err)
}
m := storage.NewMulti([]string{root1, root2})
fs := &webdavStorageFS{store: m}
if _, err := fs.Copy(context.Background(), "/"+m.Names[0]+"/a.txt", "/"+m.Names[0]+"/b.txt", &webdav.CopyOptions{}); err != nil {
t.Fatalf("copy: %v", err)
}
if _, err := os.Stat(filepath.Join(root1, "b.txt")); err != nil {
t.Fatal(err)
}
if _, err := fs.Move(context.Background(), "/"+m.Names[0]+"/b.txt", "/"+m.Names[1]+"/b.txt", &webdav.MoveOptions{}); err == nil {
t.Fatal("expected cross-root move rejection")
}
}