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,571 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user