package api // recycle_dedup_test.go — 26.9 回收与下载安全测试: // SHA512 去重(同内容单存储 + 引用计数删除)、过期回收(时间/次数/留存期)、 // 防盗链中间件、S3 直链 302 重定向。 import ( "bytes" "context" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gin-gonic/gin" "fileshare/internal/janitor" "fileshare/internal/middleware" "fileshare/internal/model" "fileshare/internal/storage" ) // ============ 辅助 ============ // uploadOK 上传文件并断言 200,返回取件码(复用 policy_test 的 uploadFile/respBody)。 func uploadOK(t *testing.T, d *Deps, name string, content []byte) string { t.Helper() w := uploadFile(d, name, content, nil) if w.Code != http.StatusOK { t.Fatalf("上传失败: %d %s", w.Code, w.Body.String()) } _, data := respBody(t, w) code, _ := data["code"].(string) if code == "" { t.Fatalf("响应缺少 code: %s", w.Body.String()) } return code } // fileByID 按 code 查记录。 func fileByCode(t *testing.T, d *Deps, code string) model.FileCodes { t.Helper() var fc model.FileCodes if err := d.DB.Where("code = ?", code).First(&fc).Error; err != nil { t.Fatalf("查询分享 %s: %v", code, err) } return fc } // objectExists 检查本地引擎对象是否存在。 func objectExists(t *testing.T, d *Deps, fc model.FileCodes) bool { t.Helper() store, err := d.storeFor(fc.Engine) if err != nil { t.Fatal(err) } ok, err := store.FileExists(context.Background(), fc.SavePath()) if err != nil { t.Fatalf("FileExists: %v", err) } return ok } // ============ SHA512 去重 ============ // TestDedupSameContentSingleObject 同内容上传两次 → 单存储对象 + 记录互引 + // 删除其一对象保留,删除最后一条对象才删除。 func TestDedupSameContentSingleObject(t *testing.T) { d := newPolicyTestDeps(t) content := []byte("dedup-me-26.9-同一个内容") code1 := uploadOK(t, d, "a.txt", content) code2 := uploadOK(t, d, "b.txt", content) if code1 == code2 { t.Fatal("两次上传应生成不同取件码") } fc1, fc2 := fileByCode(t, d, code1), fileByCode(t, d, code2) if fc1.ContentHash == nil || *fc1.ContentHash == "" { t.Fatal("第一条记录未回填 content_hash") } if fc1.ContentHash == nil || fc2.ContentHash == nil || *fc1.ContentHash != *fc2.ContentHash { t.Fatalf("两条记录哈希应一致: %v vs %v", fc1.ContentHash, fc2.ContentHash) } if fc1.UUIDFileName == nil || fc2.UUIDFileName == nil || *fc1.UUIDFileName != *fc2.UUIDFileName { t.Fatalf("去重应复用同一 UUID 文件名: %v vs %v", fc1.UUIDFileName, fc2.UUIDFileName) } if fc1.SavePath() != fc2.SavePath() { t.Fatal("去重应指向同一存储路径") } // 去重后对象应存在 if !objectExists(t, d, fc1) { t.Fatal("去重后对象应存在") } // 删除其一:对象保留(另一条仍引用);删除第二条:对象随之删除 c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodDelete, "/admin/file/delete", nil) if err := d.deleteFileCode(c, &fc1); err != nil { t.Fatalf("删除第一条: %v", err) } if !objectExists(t, d, fc2) { t.Fatal("仍有引用时对象不应被删除") } if err := d.deleteFileCode(c, &fc2); err != nil { t.Fatalf("删除第二条: %v", err) } store, _ := d.storeFor("local") if ok, _ := store.FileExists(context.Background(), fc2.SavePath()); ok { t.Fatal("最后一个引用删除后对象应被删除") } var cnt int64 d.DB.Model(&model.FileCodes{}).Count(&cnt) if cnt != 0 { t.Fatalf("记录应全部删除,剩余 %d", cnt) } } // TestDedupDisabled 不去重:两记录各自独立对象。 func TestDedupDisabled(t *testing.T) { d := newPolicyTestDeps(t) setKV(t, d, "dedup_enabled", "0") content := []byte("no-dedup-content") c1 := uploadOK(t, d, "x.txt", content) c2 := uploadOK(t, d, "y.txt", content) fc1, fc2 := fileByCode(t, d, c1), fileByCode(t, d, c2) if fc1.ContentHash != nil && *fc1.ContentHash != "" { t.Fatal("去重关闭时不应回填 content_hash") } if fc1.SavePath() == fc2.SavePath() { t.Fatal("去重关闭时不应共享路径") } } // setKV 直写 KV 并应用到内存 Config(对齐生产链路:UpdateKV 落库 + ApplyKV 生效)。 func setKV(t *testing.T, d *Deps, key, value string) { t.Helper() if err := d.Mgr.UpdateKV(context.Background(), map[string]any{key: value}); err != nil { t.Fatalf("setKV %s: %v", key, err) } d.Cfg.ApplyKV(map[string]any{key: value}) } // ============ 过期回收 ============ // makeFileRecord 直插一条文件分享记录(可指定过期形态)。 func makeFileRecord(t *testing.T, d *Deps, code string, expiredAt *time.Time, expiredCount int, createdAt time.Time) model.FileCodes { t.Helper() name := "obj-" + code + ".bin" dir := "share/data/test" store, _ := d.storeFor("local") if _, err := store.SaveFile(context.Background(), bytes.NewReader([]byte("recycle-body")), dir+"/"+name); err != nil { t.Fatalf("写入测试对象: %v", err) } fc := model.FileCodes{ Code: code, Prefix: "obj-" + code, Suffix: ".bin", UUIDFileName: &name, FilePath: &dir, Size: 12, ExpiredAt: expiredAt, ExpiredCount: expiredCount, Engine: "local", } if err := d.DB.Create(&fc).Error; err != nil { t.Fatal(err) } // 校正 created_at(GORM 自动填 now) if err := d.DB.Model(&model.FileCodes{}).Where("id = ?", fc.ID).Update("created_at", createdAt).Error; err != nil { t.Fatal(err) } fc.CreatedAt = createdAt return fc } // TestRecycleExpiredTimeAndCount 时间过期与次数耗尽都被回收。 func TestRecycleExpiredTimeAndCount(t *testing.T) { d := newPolicyTestDeps(t) past := time.Now().Add(-time.Hour) r1 := makeFileRecord(t, d, "RECYA", &past, -1, time.Now().Add(-2*time.Hour)) // 时间过期 r2 := makeFileRecord(t, d, "RECYB", &past, 0, time.Now().Add(-2*time.Hour)) // 次数耗尽 r3 := makeFileRecord(t, d, "RECYC", nil, 5, time.Now().Add(-2*time.Hour)) // 存活(无过期时间且有余量) removed := janitor.RecycleExpired(context.Background(), d.DB, d.Store, &janitor.Recycler{ Enabled: func() bool { return true }, RetentionDays: func() int64 { return 0 }, }) if removed != 2 { t.Fatalf("应回收 2 条,实际 %d", removed) } for _, fc := range []model.FileCodes{r1, r2} { var cnt int64 d.DB.Model(&model.FileCodes{}).Where("code = ?", fc.Code).Count(&cnt) if cnt != 0 { t.Fatalf("%s 记录应被回收", fc.Code) } if objectExists(t, d, fc) { t.Fatalf("%s 存储对象应被删除", fc.Code) } } var cnt int64 d.DB.Model(&model.FileCodes{}).Where("code = ?", r3.Code).Count(&cnt) if cnt != 1 { t.Fatal("存活分享不应被回收") } if !objectExists(t, d, r3) { t.Fatal("存活分享对象应保留") } } // TestRecycleRetentionDays 留存期:创建超 retention_days 的分享被回收。 func TestRecycleRetentionDays(t *testing.T) { d := newPolicyTestDeps(t) fresh := makeFileRecord(t, d, "RETEN1", nil, 5, time.Now()) // 新 stale := makeFileRecord(t, d, "RETEN2", nil, 5, time.Now().Add(-48*time.Hour)) // 超 1 天留存 removed := janitor.RecycleExpired(context.Background(), d.DB, d.Store, &janitor.Recycler{ Enabled: func() bool { return true }, RetentionDays: func() int64 { return 1 }, }) if removed != 1 { t.Fatalf("应回收 1 条,实际 %d", removed) } var cnt int64 d.DB.Model(&model.FileCodes{}).Where("code = ?", stale.Code).Count(&cnt) if cnt != 0 { t.Fatal("超留存期分享应被回收") } if !objectExists(t, d, fresh) { t.Fatal("未超留存期的分享对象应保留") } } // TestLazyRecycleOnPickup 取件次数耗尽后再取 → 惰性回收(记录与对象删除)。 func TestLazyRecycleOnPickup(t *testing.T) { d := newPolicyTestDeps(t) past := time.Now().Add(time.Hour) fc := makeFileRecord(t, d, "LAZYa", &past, 1, time.Now()) if !d.consumeUsage(invokeContext(t), &fc) { // 第一次:count 1→0 成功 t.Fatal("首次取件应成功") } if d.consumeUsage(invokeContext(t), &fc) { t.Fatal("次数耗尽后取件应失败") } // 惰性回收是异步的:同步触发一次等价清理验证语义 janitor.RecycleRecord(context.Background(), d.DB, d.Store, &fc, &janitor.Recycler{ Enabled: func() bool { return true }, RetentionDays: func() int64 { return 0 }, }) var cnt int64 d.DB.Model(&model.FileCodes{}).Where("code = ?", fc.Code).Count(&cnt) if cnt != 0 { t.Fatal("惰性回收应删除记录") } if objectExists(t, d, fc) { t.Fatal("惰性回收应删除对象") } } // invokeContext 构造带请求的测试 context。 func invokeContext(t *testing.T) *gin.Context { t.Helper() c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodGet, "/", nil) return c } // ============ 防盗链 ============ // TestHotlinkMiddleware 防盗链中间件矩阵。 func TestHotlinkMiddleware(t *testing.T) { d := newPolicyTestDeps(t) mw := hotlinkProbe(d) req := func(referer, host string) int { r := httptest.NewRequest(http.MethodGet, "/share/download", nil) if referer != "" { r.Header.Set("Referer", referer) } r.Host = host w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = r mw(c) if !c.IsAborted() { return http.StatusOK } return w.Code } setKV(t, d, "hotlink_enabled", "0") if got := req("https://evil.com/leech", "mysite.com"); got != http.StatusOK { t.Fatalf("开关关闭时应全放行,got %d", got) } setKV(t, d, "hotlink_enabled", "1") setKV(t, d, "hotlink_whitelist", "") if got := req("https://evil.com/leech", "mysite.com"); got != http.StatusForbidden { t.Fatalf("外站 Referer 应 403,got %d", got) } if got := req("https://mysite.com/page", "mysite.com"); got != http.StatusOK { t.Fatalf("同源 Referer 应放行,got %d", got) } if got := req("", "mysite.com"); got != http.StatusOK { t.Fatalf("空 Referer 应放行,got %d", got) } setKV(t, d, "hotlink_whitelist", "friend.org, *.cdn.net") if got := req("https://friend.org/x", "mysite.com"); got != http.StatusOK { t.Fatalf("白名单精确命中应放行,got %d", got) } if got := req("https://sub.cdn.net/x", "mysite.com"); got != http.StatusOK { t.Fatalf("白名单通配命中应放行,got %d", got) } if got := req("https://other.net/x", "mysite.com"); got != http.StatusForbidden { t.Fatalf("非白名单应 403,got %d", got) } } // hotlinkProbe 直接调用中间件构造器。 func hotlinkProbe(d *Deps) gin.HandlerFunc { return middleware.HotlinkMiddleware(d.Cfg) } // ============ 直链下载 ============ // presignFake 包装本地引擎,仅覆盖 PresignGetURL 返回固定签名 URL。 type presignFake struct { storage.Storage gotExpires int64 url string } func (p *presignFake) PresignGetURL(_ context.Context, _ string, expires int64) (string, error) { p.gotExpires = expires return p.url, nil } // TestDirectDownloadRedirect 直链开启 + 引擎支持 → 302 到签名 URL,且 // 有效期不超过分享剩余时效;直链关闭 → 走代理 200。 func TestDirectDownloadRedirect(t *testing.T) { d := newPolicyTestDeps(t) // 桩包装原 local 引擎:302 不落盘,代理回落时仍能读到真实对象 origLocal, err := d.storeFor("local") if err != nil { t.Fatal(err) } fake := &presignFake{Storage: origLocal, url: "https://s3.example.com/signed?X-Amz-Signature=abc"} swapLocal(t, d, fake) setKV(t, d, "direct_download", "1") setKV(t, d, "direct_link_expire", "900") content := []byte("direct-link-body") code := uploadOK(t, d, "d.txt", content) // 时间型分享剩余 5 分钟 → 直链有效期应被钳到 300s exp := time.Now().Add(5 * time.Minute) if err := d.DB.Model(&model.FileCodes{}).Where("code = ?", code). Updates(map[string]any{"expired_at": exp, "expired_count": -1}).Error; err != nil { t.Fatal(err) } fc := fileByCode(t, d, code) // 重新取(带上过期时间) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil) d.serveFile(c, &fc) if w.Code != http.StatusFound { t.Fatalf("应 302 直链,实际 %d %s", w.Code, w.Body.String()) } if loc := w.Header().Get("Location"); loc != fake.url { t.Fatalf("Location 应为签名 URL,实际 %q", loc) } if fake.gotExpires > 300 { t.Fatalf("直链有效期应被分享剩余时效钳位(≤300),实际 %d", fake.gotExpires) } // 关闭直链 → 回落代理 200 setKV(t, d, "direct_download", "0") fc2 := fileByCode(t, d, code) w2 := httptest.NewRecorder() c2, _ := gin.CreateTestContext(w2) c2.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil) d.serveFile(c2, &fc2) if w2.Code != http.StatusOK { t.Fatalf("直链关闭应走代理 200,实际 %d", w2.Code) } if !strings.Contains(w2.Body.String(), "direct-link-body") { t.Fatal("代理响应应包含文件内容") } } // TestDirectDownloadLocalFallback 本地引擎不支持直链 → 自动回落代理 200。 func TestDirectDownloadLocalFallback(t *testing.T) { d := newPolicyTestDeps(t) setKV(t, d, "direct_download", "1") code := uploadOK(t, d, "f.txt", []byte("local-fallback")) fc := fileByCode(t, d, code) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/share/download", nil) d.serveFile(c, &fc) if w.Code != http.StatusOK { t.Fatalf("本地引擎应回落代理 200,实际 %d", w.Code) } } // ============ 存储桩 ============ // swapLocal 用桩替换 local 引擎(重建 Manager,工厂恒返回桩)。 func swapLocal(t *testing.T, d *Deps, fake storage.Storage) { t.Helper() factory := func(string) (storage.Storage, error) { return fake, nil } d.Store = storage.NewManager("local", fake, factory) }