- 数据库默认文件 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 清理)
265 lines
8.0 KiB
Go
265 lines
8.0 KiB
Go
package api
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
|
||
"fileshare/internal/config"
|
||
"fileshare/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 应报错")
|
||
}
|
||
// day:7 天内合法
|
||
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)
|
||
}
|
||
}
|