Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
301 lines
9.0 KiB
Go
301 lines
9.0 KiB
Go
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(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">`)
|
|
writeResp := func(href string, isDir bool, size int) {
|
|
rt := ""
|
|
if isDir {
|
|
rt = "<d:collection/>"
|
|
}
|
|
fmt.Fprintf(&b, `<d:response><d:href>%s</d:href><d:propstat>`+
|
|
`<d:prop><d:getcontentlength>%d</d:getcontentlength>`+
|
|
`<d:getlastmodified>Wed, 08 Jul 2026 10:00:00 GMT</d:getlastmodified>`+
|
|
`<d:resourcetype>%s</d:resourcetype></d:prop>`+
|
|
`<d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`,
|
|
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(`</d:multistatus>`)
|
|
_, _ = 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)
|
|
}
|
|
}
|