package storage import ( "bytes" "context" "encoding/base64" "fmt" "io" "net" "net/http" "net/http/httptest" "strconv" "strings" "sync" "sync/atomic" "testing" "time" ) // ---- 最小 WebDAV 假服务:PUT/GET/HEAD/PROPFIND/MKCOL/DELETE + Basic/Digest 认证 ---- type davLog struct { Method string Path string Status int } type fakeDav struct { mu sync.Mutex dirs map[string]bool files map[string][]byte // 认证配置:mode = none|basic|digest;digest 配合 algo = MD5|SHA-256。 mode string username string password string realm string nonce string opaque string algo string failNext map[string]int // method → 剩余 503 次数 logs []davLog } func newFakeDav(mode string) *fakeDav { return &fakeDav{ dirs: map[string]bool{}, files: map[string][]byte{}, mode: mode, username: "fcb", password: "fcb-pass", realm: "test-realm", nonce: "dcd98b7102dd2f0e8b11d0f600bfb0c0", opaque: "5ccc069c403ebaf9f0171e9517f40e41", algo: "MD5", failNext: map[string]int{}, } } // auth 校验请求凭据;失败时写出 401 与对应挑战。 func (f *fakeDav) auth(w http.ResponseWriter, r *http.Request) bool { if f.mode == "none" { return true } h := r.Header.Get("Authorization") ok := false switch f.mode { case "basic": ok = h == "Basic "+basicAuth(f.username, f.password) case "digest": ok = f.checkDigest(r) } if ok { return true } switch f.mode { case "basic": w.Header().Set("WWW-Authenticate", `Basic realm="`+f.realm+`"`) case "digest": w.Header().Set("WWW-Authenticate", fmt.Sprintf( `Digest realm="%s", qop="auth", nonce="%s", opaque="%s", algorithm=%s, stale=false`, f.realm, f.nonce, f.opaque, f.algo)) } w.WriteHeader(http.StatusUnauthorized) return false } // checkDigest 服务端重算 RFC 2617 摘要响应。 func (f *fakeDav) checkDigest(r *http.Request) bool { h := r.Header.Get("Authorization") if !strings.HasPrefix(h, "Digest ") { return false } p := parseChallengeParams(strings.TrimSpace(h[len("Digest "):])) ha1 := hashHex(algoName(f.algo), f.username+":"+f.realm+":"+f.password) ha2 := hashHex(algoName(f.algo), strings.ToUpper(r.Method)+":"+r.URL.RequestURI()) got := hashHex(algoName(f.algo), ha1+":"+f.nonce+":"+p["nc"]+":"+p["cnonce"]+":"+p["qop"]+":"+ha2) return p["username"] == f.username && p["response"] == got } func basicAuth(user, pass string) string { return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)) } func (f *fakeDav) record(method, path string, status int) { f.logs = append(f.logs, davLog{Method: method, Path: path, Status: status}) } // maybeFail 命中失败注入时返回 true(已写出 503)。 func (f *fakeDav) maybeFail(w http.ResponseWriter, method string) bool { if f.failNext[method] > 0 { f.failNext[method]-- w.WriteHeader(http.StatusServiceUnavailable) return true } return false } func (f *fakeDav) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if !f.auth(w, r) { f.record(r.Method, r.URL.Path, http.StatusUnauthorized) return } p := strings.Trim(r.URL.Path, "/") switch r.Method { case http.MethodPut: if f.maybeFail(w, "PUT") { f.record(r.Method, p, 503) return } if parent := parentOf(p); parent != "" && !f.dirs[parent] { w.WriteHeader(http.StatusConflict) // 强制客户端先建目录 f.record(r.Method, p, 409) return } body, _ := io.ReadAll(r.Body) f.files[p] = body f.record(r.Method, p, 201) w.WriteHeader(http.StatusCreated) case http.MethodGet: if f.maybeFail(w, "GET") { f.record(r.Method, p, 503) return } data, ok := f.files[p] if !ok { w.WriteHeader(http.StatusNotFound) f.record(r.Method, p, 404) return } w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Accept-Ranges", "bytes") if rng := r.Header.Get("Range"); rng != "" { start, end := int64(0), int64(len(data))-1 spec := strings.TrimPrefix(rng, "bytes=") if strings.HasSuffix(spec, "-") { // bytes=N- → 到文件尾 if s, err := strconv.ParseInt(strings.TrimSuffix(spec, "-"), 10, 64); err == nil { start = s } } else if _, err := fmt.Sscanf(spec, "%d-%d", &start, &end); err != nil { w.WriteHeader(http.StatusBadRequest) f.record(r.Method, p, 400) return } if start < 0 || start >= int64(len(data)) { w.WriteHeader(http.StatusRequestedRangeNotSatisfiable) f.record(r.Method, p, 416) return } if end >= int64(len(data)) { end = int64(len(data)) - 1 } w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data))) w.WriteHeader(http.StatusPartialContent) _, _ = w.Write(data[start : end+1]) f.record(r.Method, p, 206) return } w.WriteHeader(http.StatusOK) _, _ = w.Write(data) f.record(r.Method, p, 200) case http.MethodHead: data, ok := f.files[p] if !ok { w.WriteHeader(http.StatusNotFound) f.record(r.Method, p, 404) return } w.Header().Set("Content-Length", strconv.Itoa(len(data))) w.WriteHeader(http.StatusOK) f.record(r.Method, p, 200) case "PROPFIND": depth := r.Header.Get("Depth") self, isDirSelf := f.stat(p) if !isDirSelf { w.WriteHeader(http.StatusNotFound) f.record(r.Method, p, 404) return } var b strings.Builder b.WriteString(`` + ``) f.writeResponse(&b, p, self) if depth == "1" && self.isDir { for _, name := range f.children(p) { child := name cs, cd := f.stat(child) f.writeResponse(&b, child, davStat{isDir: cd, size: cs.size}) } } b.WriteString(``) w.Header().Set("Content-Type", "application/xml; charset=utf-8") w.WriteHeader(http.StatusMultiStatus) _, _ = w.Write([]byte(b.String())) f.record(r.Method, p, 207) case "MKCOL": if f.dirs[p] || f.files[p] != nil { w.WriteHeader(http.StatusMethodNotAllowed) // 已存在 f.record(r.Method, p, 405) return } if parent := parentOf(p); parent != "" && !f.dirs[parent] { w.WriteHeader(http.StatusConflict) f.record(r.Method, p, 409) return } f.dirs[p] = true w.WriteHeader(http.StatusCreated) f.record(r.Method, p, 201) case http.MethodDelete: if _, ok := f.files[p]; ok { delete(f.files, p) w.WriteHeader(http.StatusNoContent) f.record(r.Method, p, 204) return } if f.dirs[p] { // 递归删除目录 prefix := p + "/" for name := range f.files { if strings.HasPrefix(name, prefix) { delete(f.files, name) } } for name := range f.dirs { if name == p || strings.HasPrefix(name+"/", prefix) { delete(f.dirs, name) } } w.WriteHeader(http.StatusNoContent) f.record(r.Method, p, 204) return } w.WriteHeader(http.StatusNotFound) f.record(r.Method, p, 404) default: w.WriteHeader(http.StatusMethodNotAllowed) f.record(r.Method, p, 405) } } type davStat struct { isDir bool size int } func (f *fakeDav) stat(p string) (davStat, bool) { if data, ok := f.files[p]; ok { return davStat{size: len(data)}, true } if f.dirs[p] { return davStat{isDir: true}, true } return davStat{}, false } func (f *fakeDav) children(p string) []string { var out []string prefix := p + "/" for name := range f.files { if strings.HasPrefix(name, prefix) && !strings.Contains(strings.TrimPrefix(name, prefix), "/") { out = append(out, name) } } for name := range f.dirs { if strings.HasPrefix(name, prefix) && !strings.Contains(strings.TrimPrefix(name, prefix), "/") { out = append(out, name) } } return out } func (f *fakeDav) writeResponse(b *strings.Builder, href string, st davStat) { b.WriteString(`/` + href + ``) if st.isDir { b.WriteString(``) } b.WriteString(`` + strconv.Itoa(st.size) + `application/octet-stream` + `HTTP/1.1 200 OK`) } func parentOf(p string) string { if i := strings.LastIndex(p, "/"); i > 0 { return p[:i] } return "" } // newTestDav 构造 WebDAV 引擎 + 假服务。 func newTestDav(t *testing.T, mode string, tweak func(o *WebDAVOptions)) (*WebDAVStorage, *fakeDav, *int32) { t.Helper() f := newFakeDav(mode) var conns int32 srv := httptest.NewUnstartedServer(f) srv.Config.ConnState = func(c net.Conn, cs http.ConnState) { if cs == http.StateNew { atomic.AddInt32(&conns, 1) } } srv.Start() t.Cleanup(srv.Close) opts := WebDAVOptions{ BaseURL: srv.URL, Username: f.username, Password: f.password, RootPath: "fcb_root", MaxRetries: 3, } if tweak != nil { tweak(&opts) } st, err := NewWebDAVStorage(opts) if err != nil { t.Fatalf("NewWebDAVStorage: %v", err) } return st, f, &conns } // TestWebDAVBasicCRUD Basic 认证下的完整 CRUD 与 Range。 func TestWebDAVBasicCRUD(t *testing.T) { st, f, _ := newTestDav(t, "basic", nil) ctx := context.Background() // 健康检查:根目录 404 → MKCOL 自建 if err := st.HealthCheck(ctx); err != nil { t.Fatalf("HealthCheck: %v", err) } if !f.dirs["fcb_root"] { t.Fatalf("根目录应被自动创建") } data := []byte("WebDAV 引擎数据 0123456789 ABCDEF") n, err := st.SaveFile(ctx, bytes.NewReader(data), "2025/08/w.bin") if err != nil { t.Fatalf("SaveFile: %v", err) } if n != int64(len(data)) { t.Fatalf("n = %d", n) } if string(f.files["fcb_root/2025/08/w.bin"]) != string(data) { t.Fatalf("PUT 内容不匹配") } // 按需建目录:两级目录都应已创建 if !f.dirs["fcb_root/2025"] || !f.dirs["fcb_root/2025/08"] { t.Fatalf("目录未按需创建: %v %v", f.dirs["fcb_root/2025"], f.dirs["fcb_root/2025/08"]) } meta, err := st.Stat(ctx, "2025/08/w.bin") if err != nil { t.Fatalf("Stat: %v", err) } if meta.Size != int64(len(data)) || !meta.AcceptRanges { t.Fatalf("Stat = %+v", meta) } if ok, _ := st.FileExists(ctx, "2025/08/w.bin"); !ok { t.Fatalf("FileExists 应为 true") } // 完整下载(对齐 go-api 约定) dl, err := st.Open(ctx, "2025/08/w.bin", nil) if err != nil { t.Fatalf("Open: %v", err) } got, err := io.ReadAll(dl) _ = dl.Close() if err != nil { t.Fatalf("read: %v", err) } if !bytes.Equal(got, data) { t.Fatalf("full mismatch") } if dl.Start != 0 || dl.End != int64(len(data))-1 || dl.Total != int64(len(data)) { t.Fatalf("full offsets = %d,%d,%d", dl.Start, dl.End, dl.Total) } // Range 下载 dl, err = st.Open(ctx, "2025/08/w.bin", &Range{Start: 2, End: 7}) if err != nil { t.Fatalf("Open range: %v", err) } got, err = io.ReadAll(dl) _ = dl.Close() if err != nil { t.Fatalf("read range: %v", err) } if !bytes.Equal(got, data[2:8]) { t.Fatalf("range mismatch") } if dl.Start != 2 || dl.End != 7 || dl.Total != int64(len(data)) { t.Fatalf("range offsets = %d,%d,%d", dl.Start, dl.End, dl.Total) } // 416(起点越界)/ 404 if _, err := st.Open(ctx, "2025/08/w.bin", &Range{Start: int64(len(data)) + 9, End: -1}); err == nil || !strings.Contains(err.Error(), ErrRangeNotSatisfiable.Error()) { t.Fatalf("want ErrRangeNotSatisfiable, got %v", err) } if _, err := st.Open(ctx, "no/such.bin", nil); err == nil || !strings.Contains(err.Error(), ErrNotFound.Error()) { t.Fatalf("want ErrNotFound, got %v", err) } // 删除 + 空父目录清理 if err := st.DeleteFile(ctx, "2025/08/w.bin"); err != nil { t.Fatalf("DeleteFile: %v", err) } if ok, _ := st.FileExists(ctx, "2025/08/w.bin"); ok { t.Fatalf("删除后仍存在") } if _, err := st.Stat(ctx, "2025/08/w.bin"); err == nil || !strings.Contains(err.Error(), ErrNotFound.Error()) { t.Fatalf("Stat 应 ErrNotFound, got %v", err) } } // TestWebDAVDigestAuth Digest(MD5)认证协商。 func TestWebDAVDigestAuth(t *testing.T) { st, f, _ := newTestDav(t, "digest", nil) ctx := context.Background() if err := st.HealthCheck(ctx); err != nil { t.Fatalf("HealthCheck(digest): %v", err) } // HealthCheck 流程应观察到 401 挑战(客户端先 Basic 探测 → 401 → Digest 重试) saw401 := false for _, l := range f.logs { if l.Status == 401 { saw401 = true } } if !saw401 { t.Fatalf("未观察到 401 挑战: %+v", f.logs) } // 认证后的 PROPFIND(Stat 已有目录)应得到 207 if _, err := st.Stat(ctx, ""); err == nil { // Stat("") 非法路径属预期;这里换用 FileExists 对已有根目录探测 _ = err } data := []byte("digest 内容") if _, err := st.SaveFile(ctx, bytes.NewReader(data), "d.bin"); err != nil { t.Fatalf("SaveFile(digest): %v", err) } dl, err := st.Open(ctx, "d.bin", nil) if err != nil { t.Fatalf("Open(digest): %v", err) } got, _ := io.ReadAll(dl) _ = dl.Close() if !bytes.Equal(got, data) { t.Fatalf("digest 下载内容不匹配") } // 全链路完成:确认存在成功的 2xx/207 请求 saw2xx := false for _, l := range f.logs { if l.Status == 207 || l.Status == 201 || l.Status == 200 { saw2xx = true } } if !saw2xx { t.Fatalf("认证后应有成功请求: %+v", f.logs) } } // TestWebDAVDigestSHA256 Digest(SHA-256)算法。 func TestWebDAVDigestSHA256(t *testing.T) { st, _, _ := newTestDav(t, "digest", nil) st.auth.mu.Lock() st.auth.algorithm = "sha-256" st.auth.mu.Unlock() // 服务端也切换到 SHA-256 重算摘要 st2, f, _ := newTestDav(t, "digest", nil) f.algo = "SHA-256" // 先让客户端完成一次 MD5 协商拿到挑战参数,再切 SHA-256 会 401 失败—— // 因此这里直接对 SHA-256 服务端做完整链路(client 首次探测 Basic→401→Digest)。 _ = st ctx := context.Background() if err := st2.HealthCheck(ctx); err != nil { t.Fatalf("HealthCheck(SHA-256): %v", err) } } // TestWebDAVDigestWrongPassword 凭据错误 → 明确报错而非重试风暴。 func TestWebDAVDigestWrongPassword(t *testing.T) { f := newFakeDav("digest") srv := httptest.NewServer(f) defer srv.Close() st, err := NewWebDAVStorage(WebDAVOptions{ BaseURL: srv.URL, Username: f.username, Password: "WRONG", RootPath: "r", MaxRetries: 1, BaseBackoff: 1, }) if err != nil { t.Fatal(err) } if err := st.HealthCheck(context.Background()); err == nil || !strings.Contains(err.Error(), "401") { t.Fatalf("错误凭据应报 401 相关错误, got %v", err) } } // TestWebDAVRetryGet 5xx 指数退避重试(GET 幂等)。 func TestWebDAVRetryGet(t *testing.T) { st, f, _ := newTestDav(t, "basic", func(o *WebDAVOptions) { o.BaseBackoff = 5 }) ctx := context.Background() data := []byte("retry target") if err := st.HealthCheck(ctx); err != nil { t.Fatal(err) } if _, err := st.SaveFile(ctx, bytes.NewReader(data), "r.bin"); err != nil { t.Fatal(err) } f.mu.Lock() f.failNext["GET"] = 2 f.mu.Unlock() dl, err := st.Open(ctx, "r.bin", nil) if err != nil { t.Fatalf("503×2 后应重试成功: %v", err) } got, _ := io.ReadAll(dl) _ = dl.Close() if !bytes.Equal(got, data) { t.Fatalf("content mismatch") } // 验证确实发了 3 次 GET gets := 0 for _, l := range f.logs { if l.Method == "GET" && strings.HasSuffix(l.Path, "r.bin") { gets++ } } if gets != 3 { t.Fatalf("GET 次数 = %d, want 3", gets) } } // TestWebDAVRetryPut 可重放 body(seekable)PUT 失败重试;不可重放不重试。 func TestWebDAVRetryPut(t *testing.T) { st, f, _ := newTestDav(t, "basic", func(o *WebDAVOptions) { o.BaseBackoff = 5 }) ctx := context.Background() if err := st.HealthCheck(ctx); err != nil { t.Fatal(err) } // seekable:重试成功 f.mu.Lock() f.failNext["PUT"] = 1 f.mu.Unlock() data := []byte("put with retry") if _, err := st.SaveFile(ctx, bytes.NewReader(data), "pr.bin"); err != nil { t.Fatalf("PUT 重试应成功: %v", err) } puts := 0 for _, l := range f.logs { if l.Method == "PUT" && strings.HasSuffix(l.Path, "pr.bin") { puts++ } } if puts != 2 { t.Fatalf("PUT 次数 = %d, want 2", puts) } // 非 seekable(io.Pipe):不重试,直接失败 f.mu.Lock() f.failNext["PUT"] = 1 f.mu.Unlock() pr, pw := io.Pipe() go func() { _, _ = pw.Write([]byte("non-seekable")) _ = pw.Close() }() if _, err := st.SaveFile(ctx, pr, "ns.bin"); err == nil { t.Fatalf("非重放 PUT 注入 503 应失败") } } // TestWebDAVConnectionReuse 连接复用:多次请求不应各建一条 TCP 连接。 func TestWebDAVConnectionReuse(t *testing.T) { st, _, conns := newTestDav(t, "basic", nil) ctx := context.Background() if err := st.HealthCheck(ctx); err != nil { t.Fatal(err) } for i := 0; i < 12; i++ { if _, err := st.SaveFile(ctx, bytes.NewReader([]byte("x")), fmt.Sprintf("reuse/%d.bin", i)); err != nil { t.Fatal(err) } if _, err := st.Stat(ctx, fmt.Sprintf("reuse/%d.bin", i)); err != nil { t.Fatal(err) } } // 25 次请求(12 PUT + 12 PROPFIND + 1 HealthCheck 的 PROPFIND/MKCOL)只允许极少量新连接 if got := atomic.LoadInt32(conns); got > 4 { t.Fatalf("新建 TCP 连接数 = %d,连接复用失效(应 ≤4)", got) } } // TestWebDAVPipeStreaming io.Pipe 流式转发:完整读取 + 提前关闭。 func TestWebDAVPipeStreaming(t *testing.T) { st, _, _ := newTestDav(t, "basic", nil) ctx := context.Background() if err := st.HealthCheck(ctx); err != nil { t.Fatal(err) } big := bytes.Repeat([]byte("0123456789abcdef"), 4096) // 64KB if _, err := st.SaveFile(ctx, bytes.NewReader(big), "big.bin"); err != nil { t.Fatal(err) } dl, err := st.Open(ctx, "big.bin", nil) if err != nil { t.Fatal(err) } got, err := io.ReadAll(dl) if err != nil { t.Fatalf("read pipe: %v", err) } _ = dl.Close() if !bytes.Equal(got, big) { t.Fatalf("pipe content mismatch") } // 提前关闭:后续读取返回错误且不挂死 dl2, err := st.Open(ctx, "big.bin", nil) if err != nil { t.Fatal(err) } buf := make([]byte, 10) if _, err := io.ReadFull(dl2, buf); err != nil { t.Fatalf("read head: %v", err) } if err := dl2.Close(); err != nil { t.Fatalf("early close: %v", err) } // ctx 取消同样会终止流 cctx, cancel := context.WithCancel(context.Background()) dl3, err := st.Open(cctx, "big.bin", nil) if err != nil { t.Fatal(err) } cancel() time.Sleep(20 * time.Millisecond) _, err = dl3.Read(buf) if err == nil { _ = dl3.Close() t.Fatalf("ctx 取消后读取应报错") } _ = dl3.Close() } // TestWebDAVChunkMerge 分片保存/合并/清理。 func TestWebDAVChunkMerge(t *testing.T) { st, f, _ := newTestDav(t, "basic", nil) ctx := context.Background() if err := st.HealthCheck(ctx); err != nil { t.Fatal(err) } savePath := "2025/09/merged.bin" uploadID := "uid-webdav" chunks := [][]byte{[]byte("AAA"), []byte("BB"), []byte("CCCC")} hashes := make([]string, len(chunks)) for i, c := range chunks { n, err := st.SaveChunk(ctx, uploadID, i, bytes.NewReader(c), savePath) if err != nil { t.Fatalf("SaveChunk %d: %v", i, err) } if n != int64(len(c)) { t.Fatalf("chunk %d size = %d", i, n) } hashes[i] = sha256Hex(c) } size, fileHash, err := st.MergeChunks(ctx, uploadID, len(chunks), func(i int) (string, error) { return hashes[i], nil }, savePath) if err != nil { t.Fatalf("MergeChunks: %v", err) } if size != 9 || fileHash != sha256Hex(bytes.Join(chunks, nil)) { t.Fatalf("merge result = %d %s", size, fileHash) } if string(f.files["fcb_root/"+savePath]) != "AAABBCCCC" { t.Fatalf("合并内容错误: %q", f.files["fcb_root/"+savePath]) } // 分片目录已清理 for k := range f.files { if strings.Contains(k, "chunks/"+uploadID) { t.Fatalf("分片残留: %s", k) } } if f.dirs["fcb_root/2025/09/chunks/"+uploadID] { t.Fatalf("分片目录残留") } } // TestWebDAVCleanChunks 清理与哈希失败路径。 func TestWebDAVCleanChunks(t *testing.T) { st, f, _ := newTestDav(t, "basic", nil) ctx := context.Background() if err := st.HealthCheck(ctx); err != nil { t.Fatal(err) } for i := 0; i < 2; i++ { if _, err := st.SaveChunk(ctx, "uidc", i, strings.NewReader("z"), "c.bin"); err != nil { t.Fatal(err) } } if err := st.CleanChunks(ctx, "uidc", "c.bin"); err != nil { t.Fatalf("CleanChunks: %v", err) } if len(f.files) != 0 { t.Fatalf("分片未清理: %v", f.files) } // 幂等 if err := st.CleanChunks(ctx, "uidc", "c.bin"); err != nil { t.Fatalf("CleanChunks idempotent: %v", err) } // 哈希不匹配 if _, err := st.SaveChunk(ctx, "uidm", 0, strings.NewReader("real"), "m.bin"); err != nil { t.Fatal(err) } if _, _, err := st.MergeChunks(ctx, "uidm", 1, func(i int) (string, error) { return sha256Hex([]byte("wrong")), nil }, "m.bin"); err == nil || !strings.Contains(err.Error(), ErrHashMismatch.Error()) { t.Fatalf("want ErrHashMismatch, got %v", err) } } // TestWebDAVTimeout 非流式操作超时:PROPFIND 响应慢于 Timeout(1s)→ context deadline exceeded。 func TestWebDAVTimeout(t *testing.T) { f := newFakeDav("basic") srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "PROPFIND" { time.Sleep(1500 * time.Millisecond) // > Timeout 1s } f.ServeHTTP(w, r) })) defer srv.Close() st, err := NewWebDAVStorage(WebDAVOptions{ BaseURL: srv.URL, Username: f.username, Password: f.password, RootPath: "r", MaxRetries: 0, Timeout: 1, BaseBackoff: 5, }) if err != nil { t.Fatal(err) } start := time.Now() err = st.HealthCheck(context.Background()) if err == nil { t.Fatalf("超时应报错") } if !strings.Contains(err.Error(), "context deadline exceeded") { t.Fatalf("应为超时错误, got %v", err) } // 单次尝试 1s 超时 + 一次重试 ≈ 2s;若超时未生效会拖满 2×1.5s if elapsed := time.Since(start); elapsed > 3500*time.Millisecond { t.Fatalf("超时未生效(耗时 %v)", elapsed) } } // TestWebDAVPresignNotSupported 预签名 → ErrNotSupported。 func TestWebDAVPresignNotSupported(t *testing.T) { st, _, _ := newTestDav(t, "basic", nil) ctx := context.Background() if _, err := st.PresignGetURL(ctx, "x.bin", 60); err == nil || !strings.Contains(err.Error(), ErrNotSupported.Error()) { t.Fatalf("want ErrNotSupported, got %v", err) } if _, err := st.PresignPutURL(ctx, "x.bin", 60); err == nil || !strings.Contains(err.Error(), ErrNotSupported.Error()) { t.Fatalf("want ErrNotSupported, got %v", err) } } // TestWebDAVFactoryRegistry 工厂构造 + Digest 全链路。 func TestWebDAVFactoryRegistry(t *testing.T) { f := newFakeDav("digest") srv := httptest.NewServer(f) defer srv.Close() prev := engineOptions.WebDAV engineOptions.WebDAV = WebDAVOptions{ BaseURL: srv.URL, Username: f.username, Password: f.password, RootPath: "factory_root", MaxRetries: 3, BaseBackoff: 5, } defer func() { engineOptions.WebDAV = prev }() st, err := NewEngine(context.Background(), "webdav") if err != nil { t.Fatalf("NewEngine(webdav): %v", err) } ctx := context.Background() if err := st.HealthCheck(ctx); err != nil { t.Fatalf("HealthCheck: %v", err) } if _, err := st.SaveFile(ctx, strings.NewReader("factory"), "f.txt"); err != nil { t.Fatalf("SaveFile: %v", err) } dl, err := st.Open(ctx, "f.txt", nil) if err != nil { t.Fatalf("Open: %v", err) } got, _ := io.ReadAll(dl) _ = dl.Close() if string(got) != "factory" { t.Fatalf("content = %q", got) } }