Files
FileShare/server/internal/api/recycle_dedup_test.go
T
SKYMirror 7a9015aad0
CI 测试 / go vet + go test (push) Failing after 5s
26.9:直链下载 + 过期回收 + SHA512 去重 + 防盗链 + 媒体预览 + 文件夹上传提示
- 对象存储直链:S3 引擎 302 到限时预签名 URL(有效期钳位分享剩余时效),失败自动回落代理
- 过期回收:janitor 定时扫描 + 取件惰性回收 + 管理端手动触发(POST /admin/recycle/run),
  retention_days 最长存储时长;删除走引用计数(去重对象安全)
- SHA512 内容去重:三条上传链路落库后计算哈希,命中即复用旧对象并删除本次副本
- 下载防盗链:Referer 白名单(同源/空 Referer/通配域名放行),挂 /share/download
- 取件页图片/音频内联预览(下载地址直连,加载失败回退下载按钮)
- 文件夹上传:拖拽目录明确提示"建议压缩后上传"(webkitGetAsEntry 探测)
- 管理端设置卡「回收与下载安全」8 个新配置键(KVSchema + configKeys + UI + i18n)
- 前端产物重建并同步 server/web/dist 与 web-embed
- 文档:10-config 配置表、03-file-share 直链/防盗链/文件夹章节、07-admin 回收端点、openapi
2026-09-08 04:54:05 +08:00

407 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_atGORM 自动填 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 应 403got %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("非白名单应 403got %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)
}