Files
xlgo-core/app_test.go
T
杭州明婳科技 6595e94677 fix: 多模型评审终审收口 — P0安全 + 主线A并发治理 + 文档对齐
经 deepseek/GLM/Claude 三轮对抗性评审 + 交叉核验 + 终审(见
last_report.md、cross_review_assessment.md),逐条回读源码核实并修复。
go build / vet / test -race ./... 全绿。

安全(P0):
- JWT alg confusion:WithValidMethods 固定 HMAC + keyfunc 方法断言
- JWT 空密钥 / 不支持算法 fail-closed(ErrEmptySecret / ErrUnsupportedAlgorithm)
- 上传大小实测封顶(enforceUploadSize),不信任客户端 file.Size
- HTTP headers/cookies map 竞态(写锁+快照)+ SSRF 防护(opt-in dialer.Control)

并发/资源(主线A 闭合):
- 包级可变全局一律 atomic.Pointer:DefaultRedis / DefaultStorage /
  DefaultManager / Validator / DefaultCache 等,消除裸指针数据竞争
- trace.Close 去 sync.Once(防 exporter 泄漏);app OnReady 失败走 Shutdown
- ws.Hub.Stop stopOnce 防 double-close;cron 接入 App 生命周期(WithCron)
- ratelimit failClosed atomic.Bool;Recover Written() 守卫
- cron checkAndRun 锁内收集锁外 spawn(wg.Add 在锁内防 Stop 竞态)

终审剩余项收口(H-A~M-H + L 系列):
- 副本连接池 MaxOpenConns/2 截断修复(replicaMaxOpenConns)
- JWT 黑名单 1s 超时 + 显式错误;ratelimit GetRedis 取一次复用
- GetPage 深分页上限;HashFile 流式;ReadFile 去 TOCTOU
- config Clone() 深拷贝;cache 锁操作返 ErrRedisNotReady
- compress 解压残留清理;正则预编译;EqualsIgnoreCase→EqualFold
- 删 redisLimiters 死代码;CLI 输入校验 + 回滚

文档:
- README/GUIDE/CHANGELOG 对齐当前 API(含破坏性变更迁移说明)
- docs/README 索引修正;config 注释修正(RS256 已不支持)

破坏性变更详见 CHANGELOG.md [Unreleased]。项目处于研发初期、无下游用户。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:16:03 +08:00

362 lines
11 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 xlgo_test
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
xlgo "github.com/EthanCodeCraft/xlgo-core"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/router"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func testConfig(port int) *config.Config {
return &config.Config{
App: config.AppConfig{Env: "test"},
Server: config.ServerConfig{Port: port, Mode: "development"},
Log: config.LogConfig{Dir: filepath.Join(os.TempDir(), "xlgo_app_test_logs"), MaxSize: 1, MaxBackups: 1, MaxAge: 1},
}
}
func writeConfig(t *testing.T, name string, port int) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, name)
content := "app:\n env: test\nserver:\n port: " + strconv.Itoa(port) + "\n mode: development\nlog:\n dir: " + filepath.ToSlash(filepath.Join(dir, "logs")) + "\n max_size: 1\n max_backups: 1\n max_age: 1\n"
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
func TestAppWithConfigPath(t *testing.T) {
path := writeConfig(t, "config.yaml", 18081)
app := xlgo.New(xlgo.WithConfigPath(path))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
if app.GetServer() != nil {
t.Fatal("Init should not start server")
}
}
func TestAppWithConfigNoDefaultRoutes(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18082)))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 without health routes, got %d", w.Code)
}
}
func TestAppWithHealthRoutes(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18083)), xlgo.WithHealthRoutes())
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected /health 200, got %d", w.Code)
}
}
func TestAppWithHealthCheckFailure(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18084)),
xlgo.WithHealthRoutes(),
xlgo.WithHealthCheck("custom", func(_ context.Context) error { return errors.New("down") }),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected /health 503, got %d", w.Code)
}
}
func TestAppMigratorRequiresMySQL(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18085)),
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
)
err := app.Init()
if err == nil || !strings.Contains(err.Error(), "未启用 MySQL") {
t.Fatalf("expected mysql disabled migrator error, got %v", err)
}
}
func TestAppRoutesModule(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18086)),
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
r.GET("/hello", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"hello": "world"}) })
})),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/hello", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected module route 200, got %d", w.Code)
}
}
func TestAppWithoutDefaultRoutesOverrides(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18087)),
xlgo.WithDefaultRoutes(),
xlgo.WithoutDefaultRoutes(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 after WithoutDefaultRoutes, got %d", w.Code)
}
}
// TestAppWithSwaggerRoutesOnly 验证可以单独启用 Swagger 而不开启 health。
func TestAppWithSwaggerRoutesOnly(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18088)),
xlgo.WithSwaggerRoutes(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
// /health 不应注册
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected /health 404 when only Swagger enabled, got %d", w.Code)
}
// /swagger/* 应注册(具体子路径返回什么取决于 swag 文档是否生成,这里只验证未 404)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/swagger/index.html", nil)
app.GetRouter().ServeHTTP(w, req)
if w.Code == http.StatusNotFound {
t.Fatalf("expected /swagger/index.html to be registered, got 404")
}
}
// TestAppWithoutAutoMigrateOverride 验证 WithoutAutoMigrate 能覆盖
// 之前通过 WithMigrator 隐式开启的迁移,不再要求 MySQL 已启用。
func TestAppWithoutAutoMigrateOverride(t *testing.T) {
migratorCalled := false
app := xlgo.New(
xlgo.WithConfig(testConfig(18089)),
xlgo.WithMigrator(func(_ *gorm.DB) error {
migratorCalled = true
return nil
}),
xlgo.WithoutAutoMigrate(),
)
if err := app.Init(); err != nil {
t.Fatalf("Init should succeed when AutoMigrate is disabled, got %v", err)
}
if migratorCalled {
t.Fatal("migrator must not be called when WithoutAutoMigrate is applied")
}
}
// TestAppDefaultsLightweight 验证 v1.0.2 起 xlgo.New 默认是轻量应用:
// 不启 MySQL/Redis/Storage、不注册 health/swagger 路由。
func TestAppDefaultsLightweight(t *testing.T) {
app := xlgo.New(xlgo.WithConfig(testConfig(18090)))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
r := app.GetRouter()
for _, path := range []string{"/health", "/swagger/index.html"} {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected %s 404 by default, got %d", path, w.Code)
}
}
// 默认未注册 MySQL/Redis 健康检查项,故 healthChecks 应为空
// 这里通过启用 health 路由后只返回 status:ok 来间接验证
app2 := xlgo.New(xlgo.WithConfig(testConfig(18091)), xlgo.WithHealthRoutes())
if err := app2.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
app2.GetRouter().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected /health 200 with no checks, got %d", w.Code)
}
}
// TestAppWithConfigPathDrivesManager 验证 WithConfigPath 真正驱动 App 自己的
// config.Manager,并把它推到全局 default,使 config.Get 能取到加载后的配置。
func TestAppWithConfigPathDrivesManager(t *testing.T) {
path := writeConfig(t, "config_drive.yaml", 18092)
app := xlgo.New(xlgo.WithConfigPath(path))
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
cfg := config.Get()
if cfg == nil {
t.Fatal("config.Get returned nil after WithConfigPath; manager not promoted to default")
}
if cfg.Server.Port != 18092 {
t.Fatalf("expected port 18092 from loaded config, got %d", cfg.Server.Port)
}
if config.GetInt("server.port") != 18092 {
t.Fatalf("expected GetInt(server.port)=18092, got %d", config.GetInt("server.port"))
}
}
// TestAppMetricsInstrumentsRegistryRoutes_H8c 端到端验证 H8c:经注册中心注册的业务路由
// 被指标中间件采集,不依赖 RegisterMetricsRoute 的调用顺序。修复前 RegisterMetricsRoute
// 用 r.Use 仅采集其后注册的路由;修复后采集中间件在 Apply 内作首个全局中间件装入。
func TestAppMetricsInstrumentsRegistryRoutes_H8c(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18093)),
xlgo.WithMetricsRoute(),
xlgo.WithModules(router.ModuleFunc(func(r *gin.RouterGroup) {
r.GET("/h8c-biz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
})),
)
if err := app.Init(); err != nil {
t.Fatalf("Init error: %v", err)
}
r := app.GetRouter()
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/h8c-biz", nil))
if w.Code != http.StatusOK {
t.Fatalf("/h8c-biz status = %d, want 200", w.Code)
}
}
// 拉取 /metrics,断言 /h8c-biz 路由被采集(route 标签存在)。
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if w.Code != http.StatusOK {
t.Fatalf("/metrics status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `route="/h8c-biz"`) {
t.Fatalf("metrics output should contain route=\"/h8c-biz\" series, got:\n%s", body)
}
}
// freePort 返回一个当前空闲的 TCP 端口。
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// TestAppOnReadyFailureReleasesPort H-1 回归:OnReady 失败时 Run 应返回错误,
// 且 HTTP 端口被释放(监听 goroutine 不泄漏)。
// 修复前 OnReady 失败直接 return,监听 goroutine 仍阻塞在 ListenAndServe
// 端口不释放、goroutine 泄漏。
func TestAppOnReadyFailureReleasesPort(t *testing.T) {
port := freePort(t)
app := xlgo.New(
xlgo.WithConfig(testConfig(port)),
xlgo.WithHook(xlgo.Hook{
Name: "fail-on-ready",
OnReady: func(*xlgo.App) error { return errors.New("boom") },
}),
)
errCh := make(chan error, 1)
go func() { errCh <- app.Run() }()
select {
case err := <-errCh:
if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected OnReady failure error, got %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("Run did not return after OnReady failure (server goroutine leaked?)")
}
// 端口应已释放:能重新 bind。容忍 TIME_WAIT 短暂残留。
deadline := time.Now().Add(3 * time.Second)
for {
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
if err == nil {
l.Close()
break
}
if time.Now().After(deadline) {
t.Fatalf("port %d not released 3s after Run returned: %v (server goroutine leaked)", port, err)
}
time.Sleep(50 * time.Millisecond)
}
}
// TestAppInitFailureCancelsGoGoroutine H-2 回归:App.Go 启动的 goroutine 在 Init 失败后
// 应因 rootCtx cancel 而退出,不泄漏。
// 修复前 Init 失败不 cancel rootCtxgoroutine 永久阻塞在 ctx.Done()。
func TestAppInitFailureCancelsGoGoroutine(t *testing.T) {
app := xlgo.New(
xlgo.WithConfig(testConfig(18087)),
// WithMigrator 无 WithMySQL → Init 确定性失败("未启用 MySQL"
xlgo.WithMigrator(func(_ *gorm.DB) error { return nil }),
)
ctxDone := make(chan struct{})
app.Go(func(ctx context.Context) {
<-ctx.Done()
close(ctxDone)
})
if err := app.Init(); err == nil {
t.Fatal("expected Init error")
}
select {
case <-ctxDone:
// 好:goroutine 在 Init 失败后退出
case <-time.After(5 * time.Second):
t.Fatal("App.Go goroutine did not exit after Init failure (rootCtx not cancelled, leaked)")
}
}