6595e94677
经 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>
362 lines
11 KiB
Go
362 lines
11 KiB
Go
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 rootCtx,goroutine 永久阻塞在 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)")
|
||
}
|
||
}
|