package webdav import ( "context" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "pilotvault/apiserver/internal/plugins" ) func TestDescriptor(t *testing.T) { p := &Plugin{} d := p.Descriptor() if d.Name != "webdav" { t.Fatalf("name = %q, want webdav", d.Name) } if d.Kind != plugins.KindBuiltin { t.Fatalf("kind = %q, want builtin", d.Kind) } if d.Category != plugins.CategoryDrivesExternal { t.Fatalf("category = %q, want %q", d.Category, plugins.CategoryDrivesExternal) } if len(d.Capabilities) == 0 { t.Fatal("expected capabilities") } // The password field must be flagged so the manager masks it, and no field // may be Required (so the plugin can be enabled as an empty master switch). for _, f := range d.ConfigFields { if f.Key == "password" && !f.Secret { t.Errorf("config field %q must be Secret", f.Key) } if f.Required { t.Errorf("config field %q must not be Required", f.Key) } } } func TestInitDefaults(t *testing.T) { p := &Plugin{} if err := p.Init(context.Background(), map[string]string{"baseURL": "https://h/dav"}); err != nil { t.Fatal(err) } if p.basePath != "." { t.Errorf("default basePath = %q, want .", p.basePath) } if p.client == nil { t.Error("Init must build an http client") } } func TestResolveConfinement(t *testing.T) { p := &Plugin{basePath: "Documents"} cases := map[string]string{ "": "Documents", "a/b.txt": "Documents/a/b.txt", "/etc/abs": "etc/abs", // leading slash → relative to dav root, not base "../../escape": "escape", // cannot climb above the root "a/../../escape": "escape", // nor via traversal "a\\b": "Documents/a/b", // backslashes normalized } for in, want := range cases { if got := p.resolve(in); got != want { t.Errorf("resolve(%q) = %q, want %q", in, got, want) } } } func TestRequestURL(t *testing.T) { p := &Plugin{baseURL: "https://cloud.example.com/remote.php/dav/files/alice/"} got, err := p.requestURL("Documents/report 1.txt", false) if err != nil { t.Fatal(err) } want := "https://cloud.example.com/remote.php/dav/files/alice/Documents/report%201.txt" if got != want { t.Errorf("requestURL = %q, want %q", got, want) } // A directory op keeps the trailing slash servers expect for collections. dir, _ := p.requestURL("Documents", true) if !strings.HasSuffix(dir, "/") { t.Errorf("dir URL %q should end with /", dir) } } func TestRequestURLRejectsBadBase(t *testing.T) { p := &Plugin{baseURL: "not-a-url"} if _, err := p.requestURL("x", false); err == nil { t.Fatal("expected error for base URL without scheme/host") } } // TestHealthCheckNoURL confirms an empty base URL is reported as down, not a panic. func TestHealthCheckNoURL(t *testing.T) { p := &Plugin{} if err := p.Init(context.Background(), nil); err != nil { t.Fatal(err) } if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDown { t.Errorf("status = %q, want down (detail=%q)", h.Status, h.Detail) } } func TestNameFromHref(t *testing.T) { cases := map[string]string{ "/dav/files/alice/report%201.txt": "report 1.txt", "http://host/dav/Photos/": "Photos", "/dav/": "dav", } for in, want := range cases { if got := nameFromHref(in); got != want { t.Errorf("nameFromHref(%q) = %q, want %q", in, got, want) } } } func TestRegistered(t *testing.T) { m := plugins.NewManager(t.TempDir() + "/plugins.json") if _, ok := m.Get("webdav"); !ok { t.Fatal("webdav not registered in the plugin manager") } } // fakeDAV is a minimal in-memory WebDAV server exercising the verbs the plugin // uses. It is not spec-complete — just enough to drive the round-trip test. type fakeDAV struct { files map[string][]byte // path (no leading slash) → contents; dirs end in "/" } func newFakeDAV() *fakeDAV { return &fakeDAV{files: map[string][]byte{ "": nil, // root collection "docs/": nil, // a subdirectory "hello.txt": []byte("hi"), // a file }} } func (f *fakeDAV) ServeHTTP(w http.ResponseWriter, r *http.Request) { if u, _, ok := r.BasicAuth(); !ok || u != "alice" { w.WriteHeader(http.StatusUnauthorized) return } key := strings.Trim(r.URL.Path, "/") switch r.Method { case "PROPFIND": f.propfind(w, r, key) case http.MethodGet: if data, ok := f.files[key]; ok && data != nil { _, _ = w.Write(data) return } w.WriteHeader(http.StatusNotFound) case http.MethodPut: body := make([]byte, r.ContentLength) _, _ = r.Body.Read(body) f.files[key] = body w.WriteHeader(http.StatusCreated) case http.MethodDelete: delete(f.files, key) w.WriteHeader(http.StatusNoContent) case "MKCOL": f.files[key+"/"] = nil w.WriteHeader(http.StatusCreated) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (f *fakeDAV) propfind(w http.ResponseWriter, r *http.Request, key string) { w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.WriteHeader(http.StatusMultiStatus) var b strings.Builder b.WriteString(``) writeResp := func(href string, isDir bool, size int) { rt := "" if isDir { rt = "" } fmt.Fprintf(&b, `%s`+ `%d`+ `Wed, 08 Jul 2026 10:00:00 GMT`+ `%s`+ `HTTP/1.1 200 OK`, href, size, rt) } // Self entry first. writeResp("/"+key, true, 0) if r.Header.Get("Depth") == "1" && key == "" { writeResp("/docs/", true, 0) writeResp("/hello.txt", false, 2) } b.WriteString(``) _, _ = w.Write([]byte(b.String())) } // TestRoundTrip drives list/stat/download/upload/delete/mkdir against the fake // server and checks the plugin's normalized responses. func TestRoundTrip(t *testing.T) { srv := httptest.NewServer(newFakeDAV()) defer srv.Close() p := &Plugin{} if err := p.Init(context.Background(), map[string]string{ "baseURL": srv.URL, "username": "alice", "password": "pw", }); err != nil { t.Fatal(err) } ctx := context.Background() // list: the self entry is dropped, leaving docs/ and hello.txt. raw, err := p.Invoke(ctx, "list", json.RawMessage(`{"path":""}`)) if err != nil { t.Fatalf("list: %v", err) } var listed struct { Entries []fileInfo `json:"entries"` } mustJSON(t, raw, &listed) if len(listed.Entries) != 2 { t.Fatalf("list returned %d entries, want 2: %+v", len(listed.Entries), listed.Entries) } var sawDir, sawFile bool for _, e := range listed.Entries { if e.Name == "docs" && e.IsDir { sawDir = true } if e.Name == "hello.txt" && !e.IsDir && e.Size == 2 { sawFile = true } } if !sawDir || !sawFile { t.Errorf("unexpected entries: %+v", listed.Entries) } // download raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"hello.txt"}`)) if err != nil { t.Fatalf("download: %v", err) } var dl struct { ContentBase64 string `json:"contentBase64"` } mustJSON(t, raw, &dl) if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "hi" { t.Errorf("download content = %q, want hi", got) } // upload → then download it back body := base64.StdEncoding.EncodeToString([]byte("new-file")) if _, err := p.Invoke(ctx, "upload", json.RawMessage(fmt.Sprintf(`{"path":"new.txt","contentBase64":%q}`, body))); err != nil { t.Fatalf("upload: %v", err) } raw, err = p.Invoke(ctx, "download", json.RawMessage(`{"path":"new.txt"}`)) if err != nil { t.Fatalf("download after upload: %v", err) } mustJSON(t, raw, &dl) if got, _ := base64.StdEncoding.DecodeString(dl.ContentBase64); string(got) != "new-file" { t.Errorf("round-tripped content = %q, want new-file", got) } // mkdir and delete should succeed without error if _, err := p.Invoke(ctx, "mkdir", json.RawMessage(`{"path":"newdir"}`)); err != nil { t.Fatalf("mkdir: %v", err) } if _, err := p.Invoke(ctx, "delete", json.RawMessage(`{"path":"new.txt"}`)); err != nil { t.Fatalf("delete: %v", err) } // health check is OK against a live (http) server, but degraded because it's plaintext if h := p.HealthCheck(ctx); h.Status != plugins.StatusDegraded { t.Errorf("health status = %q, want degraded (plaintext http); detail=%q", h.Status, h.Detail) } } // TestHealthCheckAuthFailure confirms a 401 is classified as degraded, not down. func TestHealthCheckAuthFailure(t *testing.T) { srv := httptest.NewServer(newFakeDAV()) defer srv.Close() p := &Plugin{} if err := p.Init(context.Background(), map[string]string{ "baseURL": srv.URL, "username": "wrong", "password": "pw", }); err != nil { t.Fatal(err) } if h := p.HealthCheck(context.Background()); h.Status != plugins.StatusDegraded { t.Errorf("status = %q, want degraded on 401 (detail=%q)", h.Status, h.Detail) } } func mustJSON(t *testing.T, raw json.RawMessage, v any) { t.Helper() if err := json.Unmarshal(raw, v); err != nil { t.Fatalf("unmarshal %s: %v", raw, err) } }