Files
FileShare/server/internal/database/database_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

238 lines
7.9 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.
// 数据库双方言测试(需求 ⑧):
// - sqlite:始终执行(纯 Go,临时目录建库);
// - postgres:设置 FCB_TEST_PG_DSN(真实连接串)后执行,未设置时跳过。
//
// 覆盖:Open/Migrate 全表建立、settings KV 读写、JSON 字段往返、
// 分页查询(LIMIT/OFFSET 语义)、布尔/时间字段往返 —— 双方言逐项比对。
package database_test
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gorm.io/gorm"
"filecodebox/internal/database"
"filecodebox/internal/model"
)
// pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时返回空。
func pgTestDSN(t *testing.T) string {
t.Helper()
dsn := os.Getenv("FCB_TEST_PG_DSN")
if dsn == "" {
t.Skip("未设置 FCB_TEST_PG_DSN,跳过 Postgres 双方言用例(sqlite 用例仍执行)")
}
return dsn
}
// openTestDB 按方言打开数据库并执行迁移;返回 gorm 实例与关闭函数。
func openTestDB(t *testing.T, driver, dsn string) (*gorm.DB, func()) {
t.Helper()
if dsn == "" {
// sqlite:临时文件库
dir := t.TempDir()
dsn = filepath.Join(dir, "test.db")
}
ctx := context.Background()
db, err := database.Open(ctx, database.Options{Driver: driver, DSN: dsn})
if err != nil {
t.Fatalf("[%s] Open 失败: %v", driver, err)
}
if err := database.Migrate(ctx, db); err != nil {
_ = database.Close(db)
t.Fatalf("[%s] Migrate 失败: %v", driver, err)
}
return db, func() { _ = database.Close(db) }
}
// runDialectSuite 双方言共用的行为断言集。
func runDialectSuite(t *testing.T, db *gorm.DB) {
t.Helper()
ctx := context.Background()
// —— 1. 全表建立 ——
for _, m := range model.AllModels() {
if !db.Migrator().HasTable(m) {
t.Fatalf("表 %T 未创建", m)
}
}
// —— 2. settings KV 读写 + JSON 字段往返 ——
// GORM 软特性:KeyValue.Value 为 *stringJSON 文本),双方言 text 类型
// 可重跑:先清掉同键旧行(共享测试库场景)
if err := db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).Delete(&model.KeyValue{}).Error; err != nil {
t.Fatalf("KV 旧数据清理失败: %v", err)
}
kv := map[string]any{"background_url": "https://example.com/bg.jpg", "footer_beian": "京ICP备2024000001号-1", "max_save_seconds": 3600}
raw, err := json.Marshal(kv)
if err != nil {
t.Fatalf("marshal KV: %v", err)
}
row := model.KeyValue{Key: "settings", Value: strPtr(string(raw))}
if err := db.WithContext(ctx).Create(&row).Error; err != nil {
t.Fatalf("KV 写入失败: %v", err)
}
var got model.KeyValue
if err := db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).First(&got).Error; err != nil {
t.Fatalf("KV 读取失败: %v", err)
}
parsed := map[string]any{}
if err := json.Unmarshal([]byte(*got.Value), &parsed); err != nil {
t.Fatalf("KV JSON 解析失败: %v", err)
}
if parsed["background_url"] != "https://example.com/bg.jpg" {
t.Fatalf("KV JSON 字段往返不一致: %v", parsed)
}
// 更新(先查后改,方言无关)
if err := db.WithContext(ctx).Model(&got).Update("value", strPtr(`{"notify_enabled":0}`)).Error; err != nil {
t.Fatalf("KV 更新失败: %v", err)
}
var got2 model.KeyValue
_ = db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).First(&got2)
if *got2.Value != `{"notify_enabled":0}` {
t.Fatalf("KV 更新未生效: %s", *got2.Value)
}
// —— 3. 分页查询(LIMIT/OFFSET)——
// 每次运行用随机前缀避免脏数据互相影响
prefix := fmt.Sprintf("pg%d_", time.Now().UnixNano())
for i := 0; i < 25; i++ {
fc := model.FileCodes{
Code: fmt.Sprintf("%s%03d", prefix, i),
ExpiredCount: -1,
IsChunked: i%2 == 0, // 布尔字段往返
}
if err := db.WithContext(ctx).Create(&fc).Error; err != nil {
t.Fatalf("FileCodes 写入失败: %v", err)
}
}
var page []model.FileCodes
if err := db.WithContext(ctx).
Where("code LIKE ?", prefix+"%").
Order("id ASC").
Limit(10).Offset(20).
Find(&page).Error; err != nil {
t.Fatalf("分页查询失败: %v", err)
}
if len(page) != 5 {
t.Fatalf("第二页应剩 5 条,实际 %d", len(page))
}
if page[0].Code != prefix+"020" {
t.Fatalf("分页偏移错误: %s", page[0].Code)
}
var total int64
if err := db.WithContext(ctx).Model(&model.FileCodes{}).
Where("code LIKE ?", prefix+"%").Count(&total).Error; err != nil {
t.Fatalf("计数查询失败: %v", err)
}
if total != 25 {
t.Fatalf("总数应 25,实际 %d", total)
}
// —— 4. 布尔/时间/可空字段往返 ——
now := time.Now().Truncate(time.Second) // sqlite 秒级精度
fc := model.FileCodes{
Code: prefix + "special",
ExpiredAt: &now,
ExpiredCount: 5,
Text: strPtr("你好 FileCodeBox"),
FileHash: strPtr("abc123"),
IsChunked: true,
}
if err := db.WithContext(ctx).Create(&fc).Error; err != nil {
t.Fatalf("完整字段写入失败: %v", err)
}
var back model.FileCodes
if err := db.WithContext(ctx).Where(model.FileCodes{Code: fc.Code}).First(&back).Error; err != nil {
t.Fatalf("完整字段读取失败: %v", err)
}
if back.Text == nil || *back.Text != "你好 FileCodeBox" {
t.Fatalf("text 字段往返不一致: %v", back.Text)
}
if !back.IsChunked {
t.Fatal("布尔字段往返不一致")
}
if back.ExpiredAt == nil {
t.Fatal("时间字段往返丢失")
}
if diff := back.ExpiredAt.Sub(now); diff > time.Second || diff < -time.Second {
t.Fatalf("时间字段偏差过大: %v", diff)
}
if back.FileHash == nil || *back.FileHash != "abc123" {
t.Fatalf("可空字段往返不一致: %v", back.FileHash)
}
// LOWER + LIKEadmin 列表检索路径:真实代码先对关键词小写化再拼 LIKE 模式,
// 对齐 admin.go 的 "LOWER(code) LIKE ?" 用法,双方言均支持)
var hits int64
lowerPattern := "%" + strings.ToLower(prefix+"SPECIAL") + "%"
if err := db.WithContext(ctx).Model(&model.FileCodes{}).
Where("LOWER(code) LIKE ?", lowerPattern).Count(&hits).Error; err != nil {
t.Fatalf("LOWER/LIKE 查询失败: %v", err)
}
if hits != 1 {
t.Fatalf("LOWER/LIKE 命中数应 1,实际 %d", hits)
}
// 可重跑:清理本前缀数据(共享测试库场景)
if err := db.WithContext(ctx).Where("code LIKE ?", prefix+"%").Delete(&model.FileCodes{}).Error; err != nil {
t.Fatalf("清理测试数据失败: %v", err)
}
}
func strPtr(s string) *string { return &s }
// TestSQLiteDialect sqlite(默认模式):临时文件库全流程。
func TestSQLiteDialect(t *testing.T) {
db, closeFn := openTestDB(t, "sqlite", "")
defer closeFn()
runDialectSuite(t, db)
}
// TestSQLiteInMemoryDialect sqlite 内存库(DSN 为 :memory: 等价路径场景)。
func TestSQLiteInMemoryDialect(t *testing.T) {
dir := t.TempDir()
db, closeFn := openTestDB(t, "sqlite", filepath.Join(dir, "mem.db"))
defer closeFn()
runDialectSuite(t, db)
}
// TestPostgresDialect postgres(可选模式):FCB_TEST_PG_DSN 指向真实实例。
func TestPostgresDialect(t *testing.T) {
dsn := pgTestDSN(t)
db, closeFn := openTestDB(t, "postgres", dsn)
defer closeFn()
runDialectSuite(t, db)
}
// TestOpenRejectsUnknownDriver 非法驱动应报错。
func TestOpenRejectsUnknownDriver(t *testing.T) {
if _, err := database.Open(context.Background(), database.Options{Driver: "mysql", DSN: "x"}); err == nil {
t.Fatal("非法驱动应报错")
}
}
// TestOpenPostgresRequiresDSN postgres 模式缺 DSN 应报错。
func TestOpenPostgresRequiresDSN(t *testing.T) {
if _, err := database.Open(context.Background(), database.Options{Driver: "postgres", DSN: ""}); err == nil {
t.Fatal("postgres 缺 DSN 应报错")
}
}
// TestSQLiteAutoCreatesDataDir sqlite 默认相对路径下自动创建父目录。
func TestSQLiteAutoCreatesDataDir(t *testing.T) {
dir := t.TempDir()
nested := filepath.Join(dir, "deep", "data", "fcb.db")
db, closeFn := openTestDB(t, "sqlite", nested)
defer closeFn()
if _, err := os.Stat(nested); err != nil {
t.Fatalf("数据库文件应已创建: %v", err)
}
runDialectSuite(t, db)
}