Files
PilotVault/API Server/internal/plugins/builtin/webdav/webdav_test.go
T
tajniak81andClaude Opus 4.8 afc6952eda 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>
2026-07-13 11:43:33 +02:00

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)
}
}