- 数据库默认文件 filecodebox.db → fileshare.db(config.go 默认值与全部文档/编排同步)
- Go module filecodebox → fileshare(全部 import 同步,build/vet/test 全绿)
- 应用版本 APP_VERSION 2.5.6 → 26.9(health 接口已验证返回 26.9)
- deploy 编排统一:compose 项目名、Postgres 默认凭据、minio 桶名、env 注释
- JWT issuer、存储临时目录前缀、web 包名同步 fileshare
- CI:镜像 tag 以 APP_VERSION 为唯一版本源,main/tag 推送即发布
${VER} + latest;tag 触发时校验 tag 名与 APP_VERSION 一致,防错版
- 本地开发库文件已改名 fileshare.db(含 -shm/-wal 清理)
572 lines
22 KiB
Go
572 lines
22 KiB
Go
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"
|
||
|
||
"fileshare/internal/audit"
|
||
"fileshare/internal/cache"
|
||
"fileshare/internal/config"
|
||
"fileshare/internal/database"
|
||
"fileshare/internal/middleware"
|
||
"fileshare/internal/settings"
|
||
"fileshare/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
|
||
}
|