package api import ( "bytes" "context" "encoding/json" "errors" "fmt" "mime/multipart" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/gin-gonic/gin" "filecodebox/internal/audit" "filecodebox/internal/cache" "filecodebox/internal/config" "filecodebox/internal/database" "filecodebox/internal/middleware" "filecodebox/internal/settings" "filecodebox/internal/storage" ) // ============ 测试环境装配(真实 sqlite + 内存缓存 + 本地存储)============ // newPolicyTestDeps 构造带真实依赖的 Deps:sqlite 文件库(t.TempDir)、 // 本地存储引擎、内存缓存限流器与审计服务(需求 ⑧ 默认形态)。 func newPolicyTestDeps(t *testing.T) *Deps { t.Helper() gin.SetMode(gin.TestMode) dir := t.TempDir() t.Setenv("FCB_DB_DRIVER", "sqlite") t.Setenv("FCB_DB_DSN", filepath.Join(dir, "test.db")) cfg, err := config.New() if err != nil { t.Fatalf("config.New: %v", err) } ctx := context.Background() db, err := database.Open(ctx, database.Options{Driver: config.DBDriverSQLite, DSN: filepath.Join(dir, "test.db")}) if err != nil { t.Fatalf("database.Open: %v", err) } t.Cleanup(func() { _ = database.Close(db) }) if err := database.Migrate(ctx, db); err != nil { t.Fatalf("database.Migrate: %v", err) } mgr, err := settings.NewManager(ctx, db, cfg) if err != nil { t.Fatalf("settings.NewManager: %v", err) } store, err := storage.NewLocalStorage(filepath.Join(dir, "storage")) if err != nil { t.Fatalf("storage.NewLocalStorage: %v", err) } // v3:包装为 Manager(build 直接返回 local 实例,测试无需真实多引擎) storeMgr := storage.NewManager("local", store, func(string) (storage.Storage, error) { return storage.NewLocalStorage(filepath.Join(dir, "storage")) }) return &Deps{ DB: db, Cfg: cfg, Mgr: mgr, AuditSvc: audit.NewService(audit.NewDBSink(db)), Limiter: middleware.NewRateLimiter(cache.NewMemory(), nil), Store: storeMgr, Version: "test", } } // ============ 请求构造辅助 ============ // invoke 以给定请求调用 handler 并返回响应。 func invoke(handler gin.HandlerFunc, req *http.Request) *httptest.ResponseRecorder { w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req handler(c) return w } // patchConfig 以 JSON 调用 PATCH /admin/config/update。 func patchConfig(d *Deps, patch map[string]any) *httptest.ResponseRecorder { raw, _ := json.Marshal(patch) req := httptest.NewRequest(http.MethodPatch, "/admin/config/update", bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") return invoke(d.adminConfigUpdate, req) } // getConfig 调用 GET /admin/config/get。 func getConfig(d *Deps) *httptest.ResponseRecorder { return invoke(d.adminConfigGet, httptest.NewRequest(http.MethodGet, "/admin/config/get", nil)) } // getPublicConfig 调用 GET /api/v1/config。 func getPublicConfig(d *Deps) *httptest.ResponseRecorder { return invoke(d.publicConfig, httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)) } // uploadFile 以 multipart 表单调用 POST /share/file。 func uploadFile(d *Deps, name string, content []byte, fields map[string]string) *httptest.ResponseRecorder { body := &bytes.Buffer{} mw := multipart.NewWriter(body) fw, err := mw.CreateFormFile("file", name) if err != nil { panic(err) } _, _ = fw.Write(content) for k, v := range fields { _ = mw.WriteField(k, v) } _ = mw.Close() req := httptest.NewRequest(http.MethodPost, "/share/file", body) req.Header.Set("Content-Type", mw.FormDataContentType()) return invoke(d.shareFile, req) } // chunkInitJSON 以 JSON 调用 POST /chunk/upload/init。 func chunkInitJSON(d *Deps, payload string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/chunk/upload/init", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") return invoke(d.chunkInit, req) } // respBody 解析统一响应体。 func respBody(t *testing.T, w *httptest.ResponseRecorder) (code int, data map[string]any) { t.Helper() var body struct { Code int `json:"code"` Msg string `json:"msg"` Data map[string]any `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("响应解析失败: %v; body=%s", err, w.Body.String()) } return body.Code, body.Data } // pngMagic 最小合法 PNG 头(magic 校验可识别)。 var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'} // ============ ① 公开 config:v2 展示与策略字段下发 ============ // TestPublicConfigV2Fields 验证 /api/v1/config 下发背景/页脚/备案/通知与策略范围, // 且响应不包含任何敏感键(admin_token/jwt_secret)。 func TestPublicConfigV2Fields(t *testing.T) { d := newPolicyTestDeps(t) // 管理端先设置 v2 展示字段 if w := patchConfig(d, map[string]any{ "background_url": "https://cdn.example.com/bg.png", "footer_text": "自定义页脚内容", "footer_beian": "京ICP备2024xxxxxx号-1", "notify_enabled": 0, "max_save_count": 5, }); w.Code != 200 { t.Fatalf("patchConfig 失败: %d %s", w.Code, w.Body.String()) } w := getPublicConfig(d) code, data := respBody(t, w) if code != 200 { t.Fatalf("publicConfig code=%d", code) } cfgMap, _ := data["config"].(map[string]any) if cfgMap == nil { t.Fatal("响应缺少 config 对象") } for key, want := range map[string]any{ "background_url": "https://cdn.example.com/bg.png", "footer_text": "自定义页脚内容", "footer_beian": "京ICP备2024xxxxxx号-1", "notify_enabled": float64(0), "notify_title": "系统通知", } { if got := cfgMap[key]; got != want { t.Fatalf("config.%s = %v, 期望 %v", key, got, want) } } // 策略范围 if _, ok := cfgMap["max_file_size"]; !ok { t.Fatal("config 缺少 max_file_size(存储策略)") } if _, ok := cfgMap["max_save_seconds"]; !ok { t.Fatal("config 缺少 max_save_seconds(保存时间策略)") } if got := cfgMap["max_save_count"]; got != float64(5) { t.Fatalf("config.max_save_count = %v, 期望 5", got) } if _, ok := cfgMap["allowedFileTypes"]; !ok { t.Fatal("config 缺少 allowedFileTypes") } if _, ok := cfgMap["expireStyle"]; !ok { t.Fatal("config 缺少 expireStyle") } if _, ok := cfgMap["uploadSize"]; !ok { t.Fatal("config 缺少 uploadSize") } // 敏感键绝不下发 raw := w.Body.String() if strings.Contains(raw, "admin_token") || strings.Contains(raw, "jwt_secret") { t.Fatal("公开 config 响应包含敏感键") } } // ============ ② 管理端 get/update:v2 键全链路 + 类型范围校验 ============ // TestAdminConfigV2RoundTrip 验证 v2 新键 update → get → public 的往返, // 且 admin_token 屏蔽、jwt_secret 不下发。 func TestAdminConfigV2RoundTrip(t *testing.T) { d := newPolicyTestDeps(t) patch := map[string]any{ "background_url": "https://cdn.example.com/bg.png", "footer_text": "页脚 HTML 片段", "footer_beian": "京ICP备20240001号", "notify_enabled": 0, "max_save_count": 20, "max_file_size": 5242880, "max_save_seconds": 86400, } w := patchConfig(d, patch) if w.Code != 200 { t.Fatalf("update 失败: %d %s", w.Code, w.Body.String()) } // admin config get:新键可见 + 敏感键屏蔽 w = getConfig(d) code, data := respBody(t, w) if code != 200 { t.Fatalf("get code=%d", code) } for key, want := range map[string]any{ "background_url": "https://cdn.example.com/bg.png", "footer_text": "页脚 HTML 片段", "footer_beian": "京ICP备20240001号", "notify_enabled": float64(0), "max_save_count": float64(20), "max_file_size": float64(5242880), "max_save_seconds": float64(86400), } { if got := data[key]; got != want { t.Fatalf("admin get %s = %v, 期望 %v", key, got, want) } } // v1 既有设计:admin_token 不在 configKeys 白名单(响应中不存在即屏蔽); // 兼容两种形态:键缺失或空串均算通过 if got, present := data["admin_token"]; present && got != "" { t.Fatalf("admin_token 应屏蔽(缺失或空串),实际 %v", got) } rawGet := w.Body.String() if strings.Contains(rawGet, `"jwt_secret"`) { t.Fatal("admin get 不应下发 jwt_secret") } // public config 立即反映(改策略 → 公开 config 即时更新) w = getPublicConfig(d) _, data = respBody(t, w) cfgMap := data["config"].(map[string]any) if got := cfgMap["max_file_size"]; got != float64(5242880) { t.Fatalf("public max_file_size = %v, 期望 5242880", got) } if got := cfgMap["footer_beian"]; got != "京ICP备20240001号" { t.Fatalf("public footer_beian = %v", got) } } // TestAdminConfigV2Validation 验证新键的类型与范围校验(400 + 中文错误)。 func TestAdminConfigV2Validation(t *testing.T) { d := newPolicyTestDeps(t) cases := []struct { name string patch map[string]any }{ {"max_file_size 负数", map[string]any{"max_file_size": -1}}, {"max_file_size 超上限", map[string]any{"max_file_size": config.MaxFileSizeMax + 1}}, {"max_save_count 超上限", map[string]any{"max_save_count": config.MaxSaveCountMax + 1}}, {"notify_enabled 越界", map[string]any{"notify_enabled": 2}}, {"footer_beian 超长", map[string]any{"footer_beian": strings.Repeat("备", config.FooterBeianMaxLen+1)}}, {"footer_text 超长", map[string]any{"footer_text": strings.Repeat("页", config.FooterTextMaxLen+1)}}, {"background_url 非法协议", map[string]any{"background_url": "javascript:alert(1)"}}, {"max_save_seconds 超上限", map[string]any{"max_save_seconds": config.MaxSaveSecondsMax + 1}}, {"allowed_file_types 类型错误", map[string]any{"allowed_file_types": 123}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { w := patchConfig(d, tc.patch) if w.Code != http.StatusBadRequest { t.Fatalf("应 400,实际 %d %s", w.Code, w.Body.String()) } code, _ := respBody(t, w) if code != http.StatusBadRequest { t.Fatalf("响应 code 应为 400,实际 %d", code) } }) } // 合法值不受影响 if w := patchConfig(d, map[string]any{ "max_save_count": 0, // 0 = 不限制 "notify_enabled": 1, "background_url": "data:image/png;base64,AAA", "max_file_size": 1024, "max_save_seconds": 0, }); w.Code != 200 { t.Fatalf("合法 patch 应 200: %d %s", w.Code, w.Body.String()) } } // ============ ③ 上传动态校验:admin 改策略 → 上传行为即时变化 ============ // TestUploadPolicyDynamicEnforcement 全链路:默认可传 → 改 max_file_size/白名单/ // 保存策略后 → 公开 config 反映 → 上传被新策略拒绝(403/400)。 func TestUploadPolicyDynamicEnforcement(t *testing.T) { d := newPolicyTestDeps(t) // 默认策略:小 PNG 上传成功 w := uploadFile(d, "ok.png", pngMagic, map[string]string{"expire_value": "1", "expire_style": "day"}) if code, _ := respBody(t, w); code != 200 { t.Fatalf("默认策略上传应成功: %d %s", w.Code, w.Body.String()) } // —— 大小上限:max_file_size=100 → 200B 文件 403 —— if w = patchConfig(d, map[string]any{"max_file_size": 100}); w.Code != 200 { t.Fatalf("patch max_file_size: %d %s", w.Code, w.Body.String()) } w = getPublicConfig(d) _, data := respBody(t, w) if got := data["config"].(map[string]any)["max_file_size"]; got != float64(100) { t.Fatalf("公开 config 未即时反映 max_file_size=100: %v", got) } w = uploadFile(d, "big.png", append(pngMagic, bytes.Repeat([]byte{0}, 200)...), map[string]string{"expire_value": "1", "expire_style": "day"}) code, _ := respBody(t, w) if code != http.StatusForbidden { t.Fatalf("超限上传应 403: %d %s", w.Code, w.Body.String()) } // —— 类型白名单:allowed_file_types=[.png] → .txt 403 —— if w = patchConfig(d, map[string]any{"allowed_file_types": []string{".png"}}); w.Code != 200 { t.Fatalf("patch allowed_file_types: %d %s", w.Code, w.Body.String()) } w = uploadFile(d, "note.txt", []byte("hello"), map[string]string{"expire_value": "1", "expire_style": "day"}) if code, _ := respBody(t, w); code != http.StatusForbidden { t.Fatalf("非白名单类型应 403: %d %s", w.Code, w.Body.String()) } // —— 保存时间:max_save_seconds=3600 → expire 1 天 403 —— if w = patchConfig(d, map[string]any{"max_save_seconds": 3600}); w.Code != 200 { t.Fatalf("patch max_save_seconds: %d %s", w.Code, w.Body.String()) } w = uploadFile(d, "timed.png", pngMagic, map[string]string{"expire_value": "1", "expire_style": "day"}) if code, _ := respBody(t, w); code != http.StatusForbidden { t.Fatalf("保存时间超范围应 403: %d %s", w.Code, w.Body.String()) } // —— 保存次数:max_save_count=5 → count=10 403(重置时间策略避免交叉影响)—— if w = patchConfig(d, map[string]any{"max_save_count": 5, "max_save_seconds": 0}); w.Code != 200 { t.Fatalf("patch max_save_count: %d %s", w.Code, w.Body.String()) } w = uploadFile(d, "counted.png", pngMagic, map[string]string{"expire_value": "10", "expire_style": "count"}) if code, _ := respBody(t, w); code != http.StatusForbidden { t.Fatalf("保存次数超上限应 403: %d %s", w.Code, w.Body.String()) } // 次数在上限内合法 w = uploadFile(d, "counted.png", pngMagic, map[string]string{"expire_value": "3", "expire_style": "count"}) if code, _ := respBody(t, w); code != 200 { t.Fatalf("次数在上限内应成功: %d %s", w.Code, w.Body.String()) } // —— 过期方式白名单收窄:expireStyle=[day] → hour 400 —— if w = patchConfig(d, map[string]any{"expireStyle": []string{"day"}}); w.Code != 200 { t.Fatalf("patch expireStyle: %d %s", w.Code, w.Body.String()) } w = uploadFile(d, "hour.png", pngMagic, map[string]string{"expire_value": "2", "expire_style": "hour"}) if code, _ := respBody(t, w); code != http.StatusBadRequest { t.Fatalf("非白名单 expire_style 应 400: %d %s", w.Code, w.Body.String()) } // 恢复后可用(说明策略动态读取) if w = patchConfig(d, map[string]any{"expireStyle": []string{"day", "hour", "minute", "forever", "count"}}); w.Code != 200 { t.Fatal("恢复 expireStyle 失败") } w = uploadFile(d, "hour.png", pngMagic, map[string]string{"expire_value": "2", "expire_style": "hour"}) if code, _ := respBody(t, w); code != 200 { t.Fatalf("白名单恢复后应成功: %d %s", w.Code, w.Body.String()) } } // TestChunkUploadPolicyEnforcement 验证分片上传链路接入动态策略。 func TestChunkUploadPolicyEnforcement(t *testing.T) { d := newPolicyTestDeps(t) // L4:后端强制 enableChunk 开关,本测试前置开启 if w := patchConfig(d, map[string]any{"enableChunk": 1}); w.Code != 200 { t.Fatalf("patch enableChunk: %d %s", w.Code, w.Body.String()) } // 大小:max_file_size=1000 → file_size 5000 拒绝 if w := patchConfig(d, map[string]any{"max_file_size": 1000}); w.Code != 200 { t.Fatalf("patch max_file_size: %d %s", w.Code, w.Body.String()) } w := chunkInitJSON(d, `{"file_name":"a.png","file_size":5000,"chunk_size":1024,"file_hash":"h"}`) if code, _ := respBody(t, w); code != http.StatusForbidden { t.Fatalf("分片总大小超限应 403: %d %s", w.Code, w.Body.String()) } // 类型:allowed_file_types=[.png] → b.txt 拒绝(max_file_size 重置为回落,隔离类型断言) if w := patchConfig(d, map[string]any{"allowed_file_types": []string{".png"}, "max_file_size": 0}); w.Code != 200 { t.Fatalf("patch allowed_file_types: %d %s", w.Code, w.Body.String()) } w = chunkInitJSON(d, `{"file_name":"b.txt","file_size":500,"chunk_size":1024,"file_hash":"h"}`) if code, _ := respBody(t, w); code != http.StatusForbidden { t.Fatalf("分片文件类型非白名单应 403: %d %s", w.Code, w.Body.String()) } // 白名单内 + 大小内 → 会话创建成功 w = chunkInitJSON(d, `{"file_name":"c.png","file_size":500,"chunk_size":1024,"file_hash":"h"}`) if code, _ := respBody(t, w); code != 200 { t.Fatalf("合法分片初始化应 200: %d %s", w.Code, w.Body.String()) } } // TestPresignPolicyEnforcement 验证预签名直传链路接入动态大小策略。 func TestPresignPolicyEnforcement(t *testing.T) { d := newPolicyTestDeps(t) if w := patchConfig(d, map[string]any{"max_file_size": 1000}); w.Code != 200 { t.Fatalf("patch max_file_size: %d %s", w.Code, w.Body.String()) } payload := `{"file_name":"a.png","file_size":5000,"expire_value":1,"expire_style":"day"}` req := httptest.NewRequest(http.MethodPost, "/presign/upload/init", strings.NewReader(payload)) req.Header.Set("Content-Type", "application/json") w := invoke(d.presignInit, req) if code, _ := respBody(t, w); code != http.StatusForbidden { t.Fatalf("预签名直传超限应 403: %d %s", w.Code, w.Body.String()) } } // TestSensitiveKeysNeverInPublicConfig 额外兜底:公开 config 任意策略下都无敏感键。 func TestSensitiveKeysNeverInPublicConfig(t *testing.T) { d := newPolicyTestDeps(t) // 写入敏感 KV(模拟已初始化实例),公开端点依旧不能带出 ctx := context.Background() if err := d.Mgr.UpdateKV(ctx, map[string]any{ "jwt_secret": "super-secret-value", "admin_token": settings.HashPassword("password-123456"), }); err != nil { t.Fatalf("UpdateKV: %v", err) } if err := d.Mgr.Reload(ctx); err != nil { t.Fatalf("Reload: %v", err) } raw := getPublicConfig(d).Body.String() if strings.Contains(raw, "super-secret-value") || strings.Contains(raw, "jwt_secret") { t.Fatal("公开 config 泄露 jwt_secret") } if strings.Contains(raw, "admin_token") { t.Fatal("公开 config 泄露 admin_token") } } // TestPolicySnapshotMatchesConfig 验证策略快照与 config 一致(单一读取口径)。 func TestPolicySnapshotMatchesConfig(t *testing.T) { d := newPolicyTestDeps(t) if w := patchConfig(d, map[string]any{"max_file_size": 2048, "max_save_count": 9, "max_save_seconds": 7200}); w.Code != 200 { t.Fatal("patch 失败") } pol := d.CurrentUploadPolicy() if pol.MaxFileSize != 2048 || pol.MaxSaveCount != 9 || pol.MaxSaveSeconds != 7200 { t.Fatalf("策略快照不一致: %+v", pol) } if err := pol.CheckSize(2048); err != nil { t.Fatalf("边界值应放行: %v", err) } if err := pol.CheckSize(2049); err == nil { t.Fatal("超限应拒绝") } // 0=回落 uploadSize if w := patchConfig(d, map[string]any{"max_file_size": 0}); w.Code != 200 { t.Fatal("patch 失败") } if got := d.CurrentUploadPolicy().MaxFileSize; got != d.Cfg.UploadSize() { t.Fatalf("max_file_size=0 应回落 uploadSize: %d vs %d", got, d.Cfg.UploadSize()) } } // 编译期保证 fmt 被使用(测试辅助函数中错误路径占位)。 var _ = fmt.Sprintf // ============ v3 存储引擎热切换 ============ // switchEngine 调用 POST /admin/storage/switch。 func switchEngine(d *Deps, engine string) *httptest.ResponseRecorder { raw, _ := json.Marshal(map[string]any{"engine": engine}) req := httptest.NewRequest(http.MethodPost, "/admin/storage/switch", bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") return invoke(d.adminStorageSwitch, req) } // TestAdminStorageSwitchLocal 本地引擎切换(测试 build 只产 local,切 local 恒成功)。 func TestAdminStorageSwitchLocal(t *testing.T) { d := newPolicyTestDeps(t) w := switchEngine(d, "local") if w.Code != 200 { t.Fatalf("switch local 失败: %d %s", w.Code, w.Body.String()) } // KV 持久化:admin get 可见 if got := getConfig(d); got.Code != 200 { t.Fatal("get 失败") } // 非法引擎名 400 if w := switchEngine(d, "ftp"); w.Code != http.StatusBadRequest { t.Fatalf("非法引擎应 400,实际 %d", w.Code) } } // TestAdminConfigEngineSwitchFailure 测试环境下切换到不可用引擎保持原引擎(503)。 // 测试 Manager 的 build 返回 local;这里通过直接操作 Manager 验证 503 路径的响应格式。 func TestAdminConfigEngineSwitchFailure(t *testing.T) { d := newPolicyTestDeps(t) // 用一个恒失败的 Manager 替换(模拟 s3/webdav 健康检查不过) d.Store = storage.NewManager("local", mustLocal(t), func(string) (storage.Storage, error) { return nil, errors.New("连接失败") }) w := switchEngine(d, "s3") if w.Code != http.StatusServiceUnavailable { t.Fatalf("不可用引擎应 503,实际 %d %s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "已保持原引擎") { t.Fatal("错误信息应包含「已保持原引擎」") } // 失败后当前引擎不变 if d.Store.CurrentName() != "local" { t.Fatalf("失败后应保持 local,实际 %s", d.Store.CurrentName()) } } // TestAdminConfigMaskedSecrets 敏感引擎凭据:get 掩码、update 空/掩码不落库。 func TestAdminConfigMaskedSecrets(t *testing.T) { d := newPolicyTestDeps(t) // 先写入真实凭据 if w := patchConfig(d, map[string]any{"webdav_password": "real-secret", "s3_secret_access_key": "sk-real"}); w.Code != 200 { t.Fatalf("写凭据失败: %s", w.Body.String()) } // get 应为掩码 _, data := respBody(t, getConfig(d)) if got := data["webdav_password"]; got != settings.SensitiveMaskValue { t.Fatalf("webdav_password 应掩码,实际 %v", got) } if got := data["s3_secret_access_key"]; got != settings.SensitiveMaskValue { t.Fatalf("s3_secret_access_key 应掩码,实际 %v", got) } // 提交掩码(模拟前端回显原样提交)→ 不应覆盖为掩码串 if w := patchConfig(d, map[string]any{"webdav_password": settings.SensitiveMaskValue}); w.Code != 200 { t.Fatalf("掩码提交应 200: %s", w.Body.String()) } // 提交空串 → 不修改 if w := patchConfig(d, map[string]any{"s3_secret_access_key": ""}); w.Code != 200 { t.Fatalf("空串提交应 200: %s", w.Body.String()) } // 公开 config 绝不含引擎凭据 _, pub := respBody(t, getPublicConfig(d)) rawPub, _ := json.Marshal(pub) for _, sk := range []string{"webdav_password", "s3_secret_access_key", "aws_session_token", "jwt_secret"} { if strings.Contains(string(rawPub), sk) { t.Fatalf("公开 config 不应包含 %s", sk) } } } func mustLocal(t *testing.T) storage.Storage { t.Helper() s, err := storage.NewLocalStorage(t.TempDir()) if err != nil { t.Fatal(err) } return s }