Initial commit: PilotVault multi-service project

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>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-13 11:43:33 +02:00
commit afc6952eda
172 files changed
+24591

No files matched your search

@@ -0,0 +1,551 @@
// Package webdav is a built-in plugin that connects to a WebDAV server over
// HTTP(S). It offers the same capability surface as the filetransfer plugin
// (list/stat/download/upload/delete/mkdir) but speaks WebDAV verbs — PROPFIND,
// GET, PUT, DELETE, MKCOL — directly over net/http, so it needs no third-party
// client library and cross-compiles cleanly for the Linux container.
//
// Like filetransfer, nothing connects during Init; each capability (and the
// health probe) issues its own HTTP request against the configured base URL,
// authenticating with HTTP Basic auth. This suits WebDAV, which is stateless
// per request, and keeps the plugin free of long-lived connection state.
package webdav
import (
"context"
"crypto/tls"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"sync"
"time"
"pilotvault/apiserver/internal/plugins"
)
const (
dialTimeout = 12 * time.Second
// maxReadBytes caps a download so a huge remote file can't exhaust memory;
// the health probe and Invoke both honour it.
maxReadBytes = 32 << 20 // 32 MiB
// propfindBody requests just the properties we normalize into fileInfo.
propfindBody = `<?xml version="1.0" encoding="utf-8"?>` +
`<d:propfind xmlns:d="DAV:"><d:prop>` +
`<d:displayname/><d:getcontentlength/><d:getlastmodified/><d:resourcetype/>` +
`</d:prop></d:propfind>`
)
func init() {
plugins.Register("webdav", func() plugins.Plugin { return &Plugin{} })
}
// Plugin is the WebDAV connector. All fields are guarded by mu because Init may
// run concurrently with a HealthCheck/Invoke from another request.
type Plugin struct {
mu sync.Mutex
baseURL string // e.g. https://cloud.example.com/remote.php/dav/files/alice/
username string
password string
basePath string // working root, resolved under the base URL's path
// insecureTLS skips HTTPS certificate verification when true.
insecureTLS bool
client *http.Client
}
func (p *Plugin) Descriptor() plugins.Descriptor {
return plugins.Descriptor{
Name: "webdav",
Provider: "WebDAV",
Version: "1.0.0",
Kind: plugins.KindBuiltin,
Category: plugins.CategoryDrivesExternal,
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
{ID: "list", Method: "PROPFIND", Endpoint: "/", Description: "List a remote directory. params: {path}"},
{ID: "stat", Method: "PROPFIND", Endpoint: "/", Description: "Stat one remote path. params: {path}"},
{ID: "download", Method: "GET", Endpoint: "/", Description: "Read a remote file (base64, ≤32 MiB). params: {path}"},
{ID: "upload", Method: "PUT", Endpoint: "/", Description: "Write a remote file. params: {path, contentBase64}"},
{ID: "delete", Method: "DELETE", Endpoint: "/", Description: "Delete a remote file or directory. params: {path}"},
{ID: "mkdir", Method: "MKCOL", Endpoint: "/", Description: "Create a remote directory. params: {path}"},
},
ConfigFields: []plugins.ConfigField{
// No field is Required: the plugin can be enabled as a master switch with
// an empty global config, leaving each organization or user to supply
// their own connection through the cascade (mirrors filetransfer). A
// missing base URL is reported gracefully by the health probe.
{Key: "baseURL", Label: "Server URL", Type: "text",
Help: "WebDAV endpoint, e.g. https://cloud.example.com/remote.php/dav/files/alice/ — must include the scheme."},
{Key: "username", Label: "Username", Type: "text"},
{Key: "password", Label: "Password", Type: "password", Secret: true,
Help: "Password or app-specific token for HTTP Basic auth. Leave blank for an anonymous/public share."},
{Key: "basePath", Label: "Base path", Type: "text", Default: ".",
Help: "Directory under the server URL used as the working root and probed by the health check, e.g. /Documents. Relative capability paths resolve under it."},
{Key: "insecureSkipVerify", Label: "TLS verification", Type: "select", Default: "false",
Options: []plugins.SelectOption{
{Value: "false", Label: "Verify certificate (recommended)"},
{Value: "true", Label: "Skip verification — accept any certificate"},
},
Help: "Only affects HTTPS. Skip verification only for self-signed test servers."},
},
}
}
func (p *Plugin) Init(_ context.Context, config map[string]string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.baseURL = strings.TrimSpace(config["baseURL"])
p.username = strings.TrimSpace(config["username"])
p.password = config["password"]
p.basePath = strings.TrimSpace(config["basePath"])
if p.basePath == "" {
p.basePath = "."
}
p.insecureTLS = strings.EqualFold(strings.TrimSpace(config["insecureSkipVerify"]), "true")
p.client = &http.Client{
// No client-level timeout: request lifetime is bounded by the caller's
// context so large downloads aren't cut off mid-stream.
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: dialTimeout}).DialContext,
TLSHandshakeTimeout: dialTimeout,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: p.insecureTLS, //nolint:gosec // opt-in for self-signed test servers
},
},
}
return nil
}
// resolve joins a caller-supplied path against the base path. A leading "/" is
// treated as relative to the server URL's own path root; an empty path becomes
// the base path itself. It never allows escaping above that root: the joined
// path is cleaned against a virtual "/" so "..", stray separators, and
// backslashes can't climb out.
func (p *Plugin) resolve(rel string) string {
rel = strings.TrimSpace(rel)
rel = strings.ReplaceAll(rel, "\\", "/")
base := p.basePath
if base == "." {
base = ""
}
var joined string
switch {
case rel == "":
joined = base
case strings.HasPrefix(rel, "/"):
joined = rel // relative to the server URL root, not the base path
case base == "":
joined = rel
default:
joined = base + "/" + rel
}
// Clean against a virtual root so nothing escapes above it.
return strings.TrimPrefix(path.Clean("/"+joined), "/")
}
// requestURL builds the absolute request URL for a resolved path. When dir is
// true a trailing slash is kept, which WebDAV servers expect for collection
// operations (PROPFIND/MKCOL). url.URL.String() percent-escapes the path, so
// callers pass unescaped segments.
func (p *Plugin) requestURL(resolved string, dir bool) (string, error) {
base, err := url.Parse(p.baseURL)
if err != nil {
return "", fmt.Errorf("invalid server URL: %w", err)
}
if base.Scheme == "" || base.Host == "" {
return "", errors.New("server URL must include scheme and host")
}
full := *base
full.Path = path.Join("/"+strings.Trim(base.Path, "/"), resolved)
full.RawPath = "" // force re-escaping from Path
if dir && !strings.HasSuffix(full.Path, "/") {
full.Path += "/"
}
return full.String(), nil
}
// do issues one authenticated WebDAV request and returns the response. The
// caller is responsible for closing the body.
func (p *Plugin) do(ctx context.Context, method, rawURL string, body io.Reader, headers map[string]string) (*http.Response, error) {
p.mu.Lock()
client, user, pass := p.client, p.username, p.password
p.mu.Unlock()
if client == nil {
return nil, errors.New("plugin not initialized")
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, err
}
if user != "" || pass != "" {
req.SetBasicAuth(user, pass)
}
for k, v := range headers {
req.Header.Set(k, v)
}
return client.Do(req)
}
// HealthCheck issues a PROPFIND against the base path and classifies the
// outcome. A 401/403 means the server is reachable but auth failed (degraded);
// a transport error is down.
func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health {
start := time.Now()
p.mu.Lock()
base, baseURL := p.basePath, p.baseURL
p.mu.Unlock()
if baseURL == "" {
return plugins.Health{Status: plugins.StatusDown, Detail: "no server URL configured"}
}
entries, err := p.propfind(ctx, p.resolve(""), 1)
lat := time.Since(start).Milliseconds()
if err != nil {
var he *httpError
if errors.As(err, &he) {
return plugins.Health{Status: classifyStatus(he.code), LatencyMs: lat,
Detail: fmt.Sprintf("connected but PROPFIND %q returned %d %s", base, he.code, http.StatusText(he.code))}
}
return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: err.Error()}
}
detail := fmt.Sprintf("WebDAV reachable — %d entr%s under %q", len(entries), plural(len(entries)), base)
status := plugins.StatusOK
if strings.HasPrefix(strings.ToLower(baseURL), "http://") {
status = plugins.StatusDegraded
detail += " · plaintext HTTP (no encryption)"
}
return plugins.Health{Status: status, LatencyMs: lat, Detail: detail}
}
// Invoke runs one capability against the WebDAV server.
func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) {
switch action {
case "list":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
entries, err := p.propfind(ctx, rp, 1)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": rp, "entries": entries})
case "stat":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
entries, err := p.propfind(ctx, rp, 0)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, fmt.Errorf("not found: %s", rp)
}
return json.Marshal(entries[0])
case "download":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
data, err := p.read(ctx, rp)
if err != nil {
return nil, err
}
return json.Marshal(map[string]any{
"path": rp,
"size": len(data),
"contentBase64": base64.StdEncoding.EncodeToString(data),
})
case "upload":
var in writeParams
if err := json.Unmarshal(params, &in); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
data, err := base64.StdEncoding.DecodeString(in.ContentBase64)
if err != nil {
return nil, fmt.Errorf("contentBase64 is not valid base64: %w", err)
}
rp := p.resolve(in.Path)
if err := p.write(ctx, rp, data); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": rp, "size": len(data), "ok": true})
case "delete":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
if err := p.remove(ctx, rp); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": rp, "ok": true})
case "mkdir":
var in pathParams
_ = json.Unmarshal(params, &in)
rp := p.resolve(in.Path)
if err := p.mkdir(ctx, rp); err != nil {
return nil, err
}
return json.Marshal(map[string]any{"path": rp, "ok": true})
default:
return nil, errors.New("unknown action: " + action)
}
}
func (p *Plugin) Shutdown(context.Context) error { return nil }
// pathParams / writeParams are the Invoke request shapes.
type pathParams struct {
Path string `json:"path"`
}
type writeParams struct {
Path string `json:"path"`
ContentBase64 string `json:"contentBase64"`
}
// fileInfo is the normalized directory-entry shape returned by list/stat. It
// matches filetransfer's shape so callers can treat the drives uniformly.
type fileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
ModTime string `json:"modTime,omitempty"`
}
// httpError carries a non-2xx status so HealthCheck can classify it.
type httpError struct {
code int
method string
}
func (e *httpError) Error() string {
return fmt.Sprintf("%s: %d %s", e.method, e.code, http.StatusText(e.code))
}
// ---------------------------------------------------------------------------
// WebDAV operations
// ---------------------------------------------------------------------------
// propfind lists (depth 1) or stats (depth 0) a path. For depth 1 the entry
// describing the collection itself is dropped so only children are returned.
func (p *Plugin) propfind(ctx context.Context, resolved string, depth int) ([]fileInfo, error) {
u, err := p.requestURL(resolved, true)
if err != nil {
return nil, err
}
resp, err := p.do(ctx, "PROPFIND", u, strings.NewReader(propfindBody), map[string]string{
"Depth": strconv.Itoa(depth),
"Content-Type": "application/xml; charset=utf-8",
})
if err != nil {
return nil, err
}
defer drainClose(resp.Body)
// 207 Multi-Status is the success case; 200 is tolerated for lenient servers.
if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK {
return nil, &httpError{code: resp.StatusCode, method: "PROPFIND"}
}
var ms davMultistatus
if err := xml.NewDecoder(io.LimitReader(resp.Body, maxReadBytes)).Decode(&ms); err != nil {
return nil, fmt.Errorf("parse PROPFIND response: %w", err)
}
// The request path, cleaned, is used to recognise and drop the self entry.
self := strings.Trim(resolved, "/")
out := make([]fileInfo, 0, len(ms.Responses))
for _, r := range ms.Responses {
hrefPath := hrefToPath(r.Href)
if depth == 1 && strings.Trim(hrefPath, "/") == self {
continue // the collection itself
}
out = append(out, r.toFileInfo())
}
return out, nil
}
func (p *Plugin) read(ctx context.Context, resolved string) ([]byte, error) {
u, err := p.requestURL(resolved, false)
if err != nil {
return nil, err
}
resp, err := p.do(ctx, http.MethodGet, u, nil, nil)
if err != nil {
return nil, err
}
defer drainClose(resp.Body)
if resp.StatusCode/100 != 2 {
return nil, &httpError{code: resp.StatusCode, method: "GET"}
}
return io.ReadAll(io.LimitReader(resp.Body, maxReadBytes))
}
func (p *Plugin) write(ctx context.Context, resolved string, data []byte) error {
u, err := p.requestURL(resolved, false)
if err != nil {
return err
}
resp, err := p.do(ctx, http.MethodPut, u, strings.NewReader(string(data)),
map[string]string{"Content-Type": "application/octet-stream"})
if err != nil {
return err
}
defer drainClose(resp.Body)
if resp.StatusCode/100 != 2 {
return &httpError{code: resp.StatusCode, method: "PUT"}
}
return nil
}
func (p *Plugin) remove(ctx context.Context, resolved string) error {
u, err := p.requestURL(resolved, false)
if err != nil {
return err
}
resp, err := p.do(ctx, http.MethodDelete, u, nil, nil)
if err != nil {
return err
}
defer drainClose(resp.Body)
// 404 is tolerated as already-gone.
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
return &httpError{code: resp.StatusCode, method: "DELETE"}
}
return nil
}
func (p *Plugin) mkdir(ctx context.Context, resolved string) error {
u, err := p.requestURL(resolved, true)
if err != nil {
return err
}
resp, err := p.do(ctx, "MKCOL", u, nil, nil)
if err != nil {
return err
}
defer drainClose(resp.Body)
// 405 Method Not Allowed is what most servers return when the collection
// already exists — treat it as success (idempotent mkdir).
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusMethodNotAllowed {
return &httpError{code: resp.StatusCode, method: "MKCOL"}
}
return nil
}
// ---------------------------------------------------------------------------
// PROPFIND XML shapes and helpers
// ---------------------------------------------------------------------------
type davMultistatus struct {
XMLName xml.Name `xml:"DAV: multistatus"`
Responses []davResponse `xml:"DAV: response"`
}
type davResponse struct {
Href string `xml:"DAV: href"`
Propstats []davPropstat `xml:"DAV: propstat"`
}
type davPropstat struct {
Status string `xml:"DAV: status"`
Prop davProp `xml:"DAV: prop"`
}
type davProp struct {
DisplayName string `xml:"DAV: displayname"`
ContentLen string `xml:"DAV: getcontentlength"`
LastModified string `xml:"DAV: getlastmodified"`
ResourceType davResourceType `xml:"DAV: resourcetype"`
}
type davResourceType struct {
Collection *xml.Name `xml:"DAV: collection"`
}
// toFileInfo normalizes a PROPFIND <response>, preferring the 2xx propstat.
func (r davResponse) toFileInfo() fileInfo {
fi := fileInfo{Name: nameFromHref(r.Href)}
for _, ps := range r.Propstats {
if !strings.Contains(ps.Status, " 2") { // "HTTP/1.1 200 OK"
continue
}
if ps.Prop.ResourceType.Collection != nil {
fi.IsDir = true
}
if n, err := strconv.ParseInt(strings.TrimSpace(ps.Prop.ContentLen), 10, 64); err == nil {
fi.Size = n
}
if lm := strings.TrimSpace(ps.Prop.LastModified); lm != "" {
if t, err := http.ParseTime(lm); err == nil {
fi.ModTime = t.UTC().Format(time.RFC3339)
}
}
if fi.Name == "" && strings.TrimSpace(ps.Prop.DisplayName) != "" {
fi.Name = ps.Prop.DisplayName
}
}
return fi
}
// hrefToPath extracts the URL path from an href, which may be absolute
// (http://host/a/b) or path-only (/a/b), and percent-decodes it.
func hrefToPath(href string) string {
if u, err := url.Parse(href); err == nil && u.Path != "" {
return u.Path
}
if dec, err := url.PathUnescape(href); err == nil {
return dec
}
return href
}
// nameFromHref returns the last path segment of an href, percent-decoded.
func nameFromHref(href string) string {
p := strings.TrimRight(hrefToPath(href), "/")
if i := strings.LastIndex(p, "/"); i >= 0 {
p = p[i+1:]
}
return p
}
// classifyStatus maps an HTTP status to a health status: an auth rejection means
// the server is reachable but credentials are wrong (degraded); anything else is
// down.
func classifyStatus(code int) string {
switch code {
case http.StatusUnauthorized, http.StatusForbidden:
return plugins.StatusDegraded
default:
return plugins.StatusDown
}
}
// drainClose drains and closes a response body so the connection can be reused.
func drainClose(body io.ReadCloser) {
_, _ = io.Copy(io.Discard, io.LimitReader(body, 4<<10))
_ = body.Close()
}
func plural(n int) string {
if n == 1 {
return "y"
}
return "ies"
}
@@ -0,0 +1,300 @@
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)
}
}