Files
FileShare/server/internal/api/policy_test.go
T
SKYMirror 84df9996cb
CI 测试 / go vet + go test (push) Successful in 49s
26.9:版本号统一 + CI 精简 + 前端产物重建
- 全项目版本号统一:v3.x 迭代号(26.9/26.9/26.9/26.9 及裸 v2/v3)→ 26.9,
  覆盖 Go 注释 / 文档 / openapi.yaml / README×4 / 前端源码(80+ 处)
- v31_test.go 更名 custom_code_test.go;TestV2AccessorDefaults → TestKVAccessorDefaults
- docs/api/00-overview.md 更新日志合并为单条 26.9 条目(修复错位拼接)
- .goreleaser.yaml 头部注释与实际一致(Pro 2.18.1 / GITEA_TOKEN / semver tag 要求)
- CI:release-image.yml → ci.yml,仅保留 vet+test 门禁;
  镜像发布移交 GoReleaser Pro(原 build-push 的 tag 校验与 26.9 版本方案冲突,历史 9 次失败)
- 前端重建:server/web/dist 与 web-embed 同步(docs 文案嵌入更新)
2026-09-08 03:16:51 +08:00

572 lines
22 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
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 构造带真实依赖的 Depssqlite 文件库(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)
}
// 26.9:包装为 Managerbuild 直接返回 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'}
// ============ ① 公开 config26.9 展示与策略字段下发 ============
// TestPublicConfigV2Fields 验证 /api/v1/config 下发背景/页脚/备案/通知与策略范围,
// 且响应不包含任何敏感键(admin_token/jwt_secret)。
func TestPublicConfigV2Fields(t *testing.T) {
d := newPolicyTestDeps(t)
// 管理端先设置 26.9 展示字段
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/update26.9 键全链路 + 类型范围校验 ============
// TestAdminConfigV2RoundTrip 验证 26.9 新键 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
// ============ 26.9 存储引擎热切换 ============
// 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
}