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:
commit
afc6952eda
172 files changed
+24591
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user