Files
FileShare/server/internal/api/helpers_test.go
T
SKYMirror 7f060dd0e4 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 全绿;二进制端到端冒烟通过
2026-09-05 04:22:41 +08:00

265 lines
8.0 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 (
"errors"
"fmt"
"testing"
"time"
"filecodebox/internal/config"
"filecodebox/internal/storage"
)
// newTestConfig 构造测试配置(defaults 基线,无 KV 覆盖;需求 ⑧ 默认 sqlite,无需真实数据库)。
func newTestConfig(t *testing.T) *config.Config {
t.Helper()
t.Setenv("FCB_DB_DRIVER", "sqlite")
t.Setenv("FCB_DB_DSN", "")
cfg, err := config.New()
if err != nil {
t.Fatalf("config.New 失败: %v", err)
}
return cfg
}
// TestGetSelectTokenWindow 验证下载令牌的窗口号逻辑(对齐参考 get_select_token)。
func TestGetSelectTokenWindow(t *testing.T) {
code := "AB12C"
secret := "test-secret"
tok0 := GetSelectToken(code, secret, 0)
tok1 := GetSelectToken(code, secret, 1)
if tok0 == "" || tok1 == "" {
t.Fatal("令牌不应为空")
}
// 同一窗口内 offset=0 的两次生成必须一致(确定性问题)
if tok0 != GetSelectToken(code, secret, 0) {
t.Fatal("同窗口令牌应确定一致")
}
// 不同 offset 的令牌必然不同(窗口号不同)
if tok0 == tok1 {
t.Fatal("offset=0 与 offset=1 的令牌应不同")
}
// 不同 code / secret 的令牌不同
if tok0 == GetSelectToken("ZZ999", secret, 0) {
t.Fatal("不同取件码的令牌应不同")
}
if tok0 == GetSelectToken(code, "other-secret", 0) {
t.Fatal("不同密钥的令牌应不同")
}
// 令牌为 64 位十六进制(sha256 hex
if len(tok0) != 64 {
t.Fatalf("令牌长度应为 64,实际 %d", len(tok0))
}
// 窗口号公式:unix/1000 - offset(秒级窗口约 16.7 分钟)
now := time.Now().Unix()
if now/1000 == (now+1100)/1000 {
// 仅当测试跨越窗口边界才跳过该断言(罕见,容忍)
t.Log("测试跨越窗口边界,跳过窗口公式断言")
}
}
// TestMapStorageError 验证存储哨兵错误→HTTP 状态映射表。
func TestMapStorageError(t *testing.T) {
cases := []struct {
name string
err error
expect int
}{
{"NotFound 直返", storage.ErrNotFound, 404},
{"NotFound 包装", fmt.Errorf("引擎内层: %w", storage.ErrNotFound), 404},
{"InvalidPath", storage.ErrInvalidPath, 400},
{"InvalidPath 包装", fmt.Errorf("webdav: %w", storage.ErrInvalidPath), 400},
{"Unavailable", storage.ErrUnavailable, 503},
{"Unavailable 包装", fmt.Errorf("s3: %w", storage.ErrUnavailable), 503},
{"NotSupported", storage.ErrNotSupported, 501},
{"NotSupported 包装", fmt.Errorf("local: %w", storage.ErrNotSupported), 501},
{"RangeNotSatisfiable", storage.ErrRangeNotSatisfiable, 416},
{"HashMismatch", storage.ErrHashMismatch, 400},
{"未知错误", errors.New("其他错误"), 500},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mapped := mapStorageError(tc.err)
var ae *apiError
if !errors.As(mapped, &ae) {
t.Fatalf("应映射为 apiError,得到 %T", mapped)
}
if ae.Status != tc.expect {
t.Fatalf("状态码应为 %d,实际 %d", tc.expect, ae.Status)
}
})
}
}
// TestResolveExpire 验证过期策略解析(白名单/上限/各 style)。
func TestResolveExpire(t *testing.T) {
cfg := newTestConfig(t)
// 非法 style
if _, err := resolveExpire(cfg, 1, "year"); err == nil {
t.Fatal("非白名单 style 应报错")
}
// 非法 value
if _, err := resolveExpire(cfg, 0, "day"); err == nil {
t.Fatal("expire_value<=0 应报错")
}
// day7 天内合法
res, err := resolveExpire(cfg, 3, "day")
if err != nil {
t.Fatalf("3 天应合法: %v", err)
}
if res.ExpiredAt == nil || res.ExpiredCount != -1 {
t.Fatal("day 类型应有 expired_at 且 expired_count=-1")
}
// 超过 7 天上限
if _, err := resolveExpire(cfg, 30, "day"); err == nil {
t.Fatal("超过 7 天上限应报错")
}
// count:按次数
res, err = resolveExpire(cfg, 5, "count")
if err != nil {
t.Fatalf("count 应合法: %v", err)
}
if res.ExpiredCount != 5 {
t.Fatalf("count 类型 expired_count 应为 5,实际 %d", res.ExpiredCount)
}
// forever:永久
res, err = resolveExpire(cfg, 1, "forever")
if err != nil {
t.Fatalf("forever 应合法: %v", err)
}
if res.ExpiredAt != nil || res.ExpiredCount != -1 {
t.Fatal("forever 应为 expired_at=nil 且 expired_count=-1")
}
}
// TestGenerateCode 验证取件码格式。
func TestGenerateCode(t *testing.T) {
for i := 0; i < 50; i++ {
num := generateCode("number")
if len(num) != 5 {
t.Fatalf("数字码应为 5 位,实际 %q", num)
}
for _, ch := range num {
if ch < '0' || ch > '9' {
t.Fatalf("数字码含非数字字符: %q", num)
}
}
secret := generateCode("secret")
if len(secret) != 5 {
t.Fatalf("字符码应为 5 位,实际 %q", secret)
}
}
}
// TestParseRangeHeader 验证 Range 头解析(对齐 HTTP 语义)。
func TestParseRangeHeader(t *testing.T) {
// 全量(无 Range
if parseRangeHeader("", 1000) != nil {
t.Fatal("无 Range 头应返回 nil")
}
// 标准区间
r := parseRangeHeader("bytes=0-99", 1000)
if r == nil || r.Start != 0 || r.End != 99 {
t.Fatalf("bytes=0-99 解析错误: %+v", r)
}
// 开区间到末尾
r = parseRangeHeader("bytes=500-", 1000)
if r == nil || r.Start != 500 || r.End != -1 {
t.Fatalf("bytes=500- 解析错误: %+v", r)
}
// 后缀区间(最后 100 字节)
r = parseRangeHeader("bytes=-100", 1000)
if r == nil || r.Start != 900 || r.End != -1 {
t.Fatalf("bytes=-100 解析错误: %+v", r)
}
// 后缀超长:截断到全文件
r = parseRangeHeader("bytes=-5000", 1000)
if r == nil || r.Start != 0 {
t.Fatalf("bytes=-5000 应从头开始: %+v", r)
}
// 多区间不支持→回退全量
if parseRangeHeader("bytes=0-1,5-6", 1000) != nil {
t.Fatal("多区间应返回 nil(回退全量)")
}
// 非法格式
if parseRangeHeader("items=0-1", 1000) != nil {
t.Fatal("非 bytes 单位应返回 nil")
}
if parseRangeHeader("bytes=abc-", 1000) != nil {
t.Fatal("非法数字应返回 nil")
}
}
// TestParseISOTime 验证时间解析的多格式兼容。
func TestParseISOTime(t *testing.T) {
valid := []string{
"2025-01-01T00:00:00Z",
"2025-01-01T08:00:00+08:00",
"2025-01-01 08:00:00",
"2025-01-01",
}
for _, s := range valid {
if _, err := parseISOTime(s); err != nil {
t.Fatalf("%q 应解析成功: %v", s, err)
}
}
if _, err := parseISOTime("not-a-time"); err == nil {
t.Fatal("非法时间应报错")
}
if _, err := parseISOTime(""); err == nil {
t.Fatal("空串应报错")
}
}
// TestFormatDurationCN 验证中文时长描述。
func TestFormatDurationCN(t *testing.T) {
cases := []struct {
d time.Duration
expect string
}{
{7 * 24 * time.Hour, "7天"},
{90 * time.Minute, "1小时30分钟"},
{45 * time.Second, "45秒"},
}
for _, tc := range cases {
if got := formatDurationCN(tc.d); got != tc.expect {
t.Fatalf("formatDurationCN(%v)=%q,期望 %q", tc.d, got, tc.expect)
}
}
}
// TestFileMagicValidation 验证 magic bytes 防伪造。
func TestFileMagicValidation(t *testing.T) {
cfg := newTestConfig(t)
// 白名单 * 全放行
if err := validateFileMagic(cfg, "a.txt", "", nil); err != nil {
t.Fatalf("白名单 * 应放行: %v", err)
}
// PNG 内容 + .exe 扩展名 → 拒绝(伪造)
if err := validateFileMagic(cfg, "evil.exe", "", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}); err == nil {
t.Fatal("PNG 内容伪装 exe 应拒绝")
}
// PNG 内容 + .png 扩展名 → 通过
if err := validateFileMagic(cfg, "ok.png", "image/png", []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}); err != nil {
t.Fatalf("真 PNG 应通过: %v", err)
}
// 文本内容 + .png 扩展名 → 拒绝
if err := validateFileMagic(cfg, "fake.png", "", []byte("hello world, this is text")); err == nil {
t.Fatal("文本伪装 png 应拒绝")
}
}
// TestSanitizePathBuild 验证存储路径构造不含穿越。
func TestSanitizePathBuild(t *testing.T) {
cfg := newTestConfig(t)
_, _, _, clean, savePath := buildSavePath(cfg, "../../etc/passwd", "uuid-123")
if clean != "etc_passwd" && clean != "passwd" {
t.Logf("清理后的文件名: %q", clean)
}
if _, ok := storage.SanitizePath(savePath); !ok {
t.Fatalf("构造的 savePath 应通过安全校验: %q", savePath)
}
}