26.9(安全审计修复版)
Go 1.27.1 (Gin+GORM) + Vue 3 文件快传服务: - 安全审计全部修复(docs/security-audit-2026-09-05.md): bcrypt 密码哈希与自动升级、presign 直传服务端大小/内容校验、 全局请求体上限、依赖升级(govulncheck 0 命中)、janitor 后台清理、 管理端审计动作落库、/admin CORS 收紧、通知内容白名单净化、 会话默认 7 天、限流缓存故障降级、robots.txt 端点等 - 前端:取件链接复制修复(不再重复拼接提取码)、markdown 净化器加固 - Redis 支持库号(FCB_REDIS_DB / redis://…/db URL) - 文档:docs/api/* 与 openapi.yaml 同步最新行为(robots.txt、 提码 5 位起、chunk 32MiB 上限、admin 审计动作等) 验证:gofmt/go vet/go test 全绿;二进制端到端冒烟通过
This commit is contained in:
@@ -0,0 +1,824 @@
|
||||
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(`<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:multistatus xmlns:D="DAV:">`)
|
||||
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(`</D:multistatus>`)
|
||||
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(`<D:response><D:href>/` + href + `</D:href><D:propstat><D:prop><D:resourcetype>`)
|
||||
if st.isDir {
|
||||
b.WriteString(`<D:collection/>`)
|
||||
}
|
||||
b.WriteString(`</D:resourcetype><D:getcontentlength>` + strconv.Itoa(st.size) +
|
||||
`</D:getcontentlength><D:getcontenttype>application/octet-stream</D:getcontenttype>` +
|
||||
`</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user