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:
2026-09-05 04:22:41 +08:00
commit 7f060dd0e4
173 changed files with 32455 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
package storage
import (
"context"
"errors"
"io"
"strings"
"sync/atomic"
"testing"
)
// fakeEngine 可配置健康检查结果的桩引擎。
type fakeEngine struct{ failHealth bool }
func (f *fakeEngine) SaveFile(ctx context.Context, r io.Reader, savePath string) (int64, error) {
return 0, nil
}
func (f *fakeEngine) DeleteFile(ctx context.Context, savePath string) error { return nil }
func (f *fakeEngine) Open(ctx context.Context, savePath string, rng *Range) (*Download, error) {
return nil, ErrNotFound
}
func (f *fakeEngine) Stat(ctx context.Context, savePath string) (*FileMeta, error) {
return nil, ErrNotFound
}
func (f *fakeEngine) SaveChunk(ctx context.Context, uploadID string, chunkIndex int, r io.Reader, savePath string) (int64, error) {
return 0, nil
}
func (f *fakeEngine) MergeChunks(ctx context.Context, uploadID string, total int, verifyHash func(index int) (string, error), savePath string) (int64, string, error) {
return 0, "", nil
}
func (f *fakeEngine) CleanChunks(ctx context.Context, uploadID string, savePath string) error {
return nil
}
func (f *fakeEngine) FileExists(ctx context.Context, savePath string) (bool, error) {
return false, nil
}
func (f *fakeEngine) HeadMeta(ctx context.Context, savePath string, headBytes int64) (*FileMeta, []byte, error) {
return nil, nil, ErrNotSupported
}
func (f *fakeEngine) PresignGetURL(ctx context.Context, savePath string, expires int64) (string, error) {
return "", ErrNotSupported
}
func (f *fakeEngine) PresignPutURL(ctx context.Context, savePath string, expires int64) (string, error) {
return "", ErrNotSupported
}
func (f *fakeEngine) HealthCheck(ctx context.Context) error {
if f.failHealth {
return ErrUnavailable
}
return nil
}
// newTestManager 构造测试用 Managerlocal 健康引擎起步;s3/webdav 由计数器控制健康。
func newTestManager(s3Fail *atomic.Bool) *Manager {
build := func(name string) (Storage, error) {
switch name {
case "local":
return &fakeEngine{}, nil
case "s3":
return &fakeEngine{failHealth: s3Fail.Load()}, nil
case "webdav":
return &fakeEngine{}, nil
}
return nil, errors.New("unknown")
}
return NewManager("local", &fakeEngine{}, build)
}
// TestSwitchSuccessAndCurrentName 切换成功后当前引擎名与实例更新。
func TestSwitchSuccessAndCurrentName(t *testing.T) {
var s3Fail atomic.Bool
m := newTestManager(&s3Fail)
if m.CurrentName() != "local" {
t.Fatalf("初始引擎应为 local,得到 %s", m.CurrentName())
}
if _, err := m.Switch("s3"); err != nil {
t.Fatalf("Switch(s3) 失败: %v", err)
}
if m.CurrentName() != "s3" {
t.Fatalf("切换后引擎应为 s3,得到 %s", m.CurrentName())
}
if _, err := m.Switch("webdav"); err != nil {
t.Fatalf("Switch(webdav) 失败: %v", err)
}
if m.CurrentName() != "webdav" {
t.Fatalf("切换后引擎应为 webdav,得到 %s", m.CurrentName())
}
}
// TestSwitchFailureKeepsCurrent 健康检查失败时保持原引擎(v3 核心语义)。
func TestSwitchFailureKeepsCurrent(t *testing.T) {
var s3Fail atomic.Bool
s3Fail.Store(true) // s3 不健康
m := newTestManager(&s3Fail)
if _, err := m.Switch("s3"); err == nil {
t.Fatal("s3 不健康时 Switch 应失败")
}
if m.CurrentName() != "local" {
t.Fatalf("切换失败后应保持 local,得到 %s", m.CurrentName())
}
// 恢复健康后可切换成功
s3Fail.Store(false)
if _, err := m.Switch("s3"); err != nil {
t.Fatalf("恢复健康后 Switch(s3) 应成功: %v", err)
}
if m.CurrentName() != "s3" {
t.Fatalf("恢复后引擎应为 s3,得到 %s", m.CurrentName())
}
}
// TestSwitchInvalidName 非法引擎名拒绝。
func TestSwitchInvalidName(t *testing.T) {
m := newTestManager(&atomic.Bool{})
if _, err := m.Switch("ftp"); err == nil || !strings.Contains(err.Error(), "未知存储引擎") {
t.Fatalf("非法引擎名应报未知存储引擎,得到 %v", err)
}
if !ValidEngine("local") || ValidEngine("ftp") {
t.Fatal("ValidEngine 判定错误")
}
}
// TestEngineOfCacheAndInvalidate EngineOf 缓存命中 + Invalidate 后重建(参数生效路径)。
func TestEngineOfCacheAndInvalidate(t *testing.T) {
var builds atomic.Int64
build := func(name string) (Storage, error) {
builds.Add(1)
return &fakeEngine{failHealth: false}, nil
}
m := NewManager("local", &fakeEngine{}, build)
s1, err := m.EngineOf("s3")
if err != nil {
t.Fatalf("EngineOf(s3): %v", err)
}
s2, err := m.EngineOf("s3")
if err != nil {
t.Fatalf("EngineOf(s3) second: %v", err)
}
if s1 != s2 {
t.Fatal("EngineOf 应命中缓存返回同一实例")
}
if n := builds.Load(); n != 1 {
t.Fatalf("应只构建 1 次,实际 %d", n)
}
// Invalidate 后下次取重建新实例
m.Invalidate("s3")
s3, err := m.EngineOf("s3")
if err != nil {
t.Fatalf("EngineOf(s3) after invalidate: %v", err)
}
if s3 == s1 {
t.Fatal("Invalidate 后应返回重建的新实例")
}
if n := builds.Load(); n != 2 {
t.Fatalf("Invalidate 后应再构建 1 次,实际累计 %d", n)
}
}
// TestInvalidateCurrentNoop Invalidate 当前引擎不生效(热路径实例保持)。
func TestInvalidateCurrentNoop(t *testing.T) {
m := newTestManager(&atomic.Bool{})
cur := m.Current()
m.Invalidate("local") // 当前引擎:应为 no-op
if m.Current() != cur {
t.Fatal("Invalidate 当前引擎不应替换实例")
}
}
// TestSwitchSameNameNoop 同名 Switch 幂等。
func TestSwitchSameNameNoop(t *testing.T) {
m := newTestManager(&atomic.Bool{})
s, err := m.Switch("local")
if err != nil {
t.Fatalf("Switch(local) 同名应成功: %v", err)
}
if s != m.Current() {
t.Fatal("同名 Switch 应返回当前实例")
}
}
// TestDelegateToCurrent 保存/读取类操作委托当前引擎(切换后指向新引擎)。
func TestDelegateToCurrent(t *testing.T) {
var s3Fail atomic.Bool
m := newTestManager(&s3Fail)
ctx := context.Background()
// local 引擎 HealthCheck 健康
if err := m.HealthCheck(ctx); err != nil {
t.Fatalf("委托 HealthCheck(local): %v", err)
}
if _, err := m.Switch("s3"); err != nil {
t.Fatalf("Switch(s3): %v", err)
}
if err := m.HealthCheck(ctx); err != nil {
t.Fatalf("委托 HealthCheck(s3): %v", err)
}
}