178 lines
5.1 KiB
Go
178 lines
5.1 KiB
Go
package middleware
|
|
|
|
import (
|
|
"errors"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"syscall"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zaptest/observer"
|
|
|
|
"github.com/dujiao-next/internal/logger"
|
|
)
|
|
|
|
// captureLogs 把 logger.L 临时替换为 zap/observer 实例,返回观察句柄与还原函数。
|
|
// 这样可以断言 RecoveryMiddleware 究竟写了哪些结构化字段,而不是只测响应码。
|
|
func captureLogs(t *testing.T) (*observer.ObservedLogs, func()) {
|
|
t.Helper()
|
|
core, observed := observer.New(zap.DebugLevel)
|
|
original := logger.L
|
|
logger.L = zap.New(core)
|
|
return observed, func() { logger.L = original }
|
|
}
|
|
|
|
func TestRecoveryMiddleware_RecoversPanicAndLogsStructured(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
observed, restore := captureLogs(t)
|
|
defer restore()
|
|
|
|
r := gin.New()
|
|
r.Use(RequestIDMiddleware())
|
|
r.Use(RecoveryMiddleware())
|
|
r.GET("/boom", func(c *gin.Context) {
|
|
panic("simulated panic")
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
|
|
req.Header.Set(requestIDHeader, "req-test-001")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", w.Code)
|
|
}
|
|
if w.Body.Len() == 0 {
|
|
t.Fatalf("expected non-empty 500 body for panic")
|
|
}
|
|
|
|
entries := observed.FilterMessage("panic_recovered").All()
|
|
if len(entries) != 1 {
|
|
t.Fatalf("expected 1 panic_recovered entry, got %d", len(entries))
|
|
}
|
|
fields := entries[0].ContextMap()
|
|
if got, _ := fields["request_id"].(string); got != "req-test-001" {
|
|
t.Errorf("request_id field = %q, want %q", got, "req-test-001")
|
|
}
|
|
if got, _ := fields["method"].(string); got != http.MethodGet {
|
|
t.Errorf("method field = %q, want %q", got, http.MethodGet)
|
|
}
|
|
if got, _ := fields["path"].(string); got != "/boom" {
|
|
t.Errorf("path field = %q, want %q", got, "/boom")
|
|
}
|
|
if _, ok := fields["stack"]; !ok {
|
|
t.Errorf("stack field missing")
|
|
}
|
|
if _, ok := fields["client_ip"]; !ok {
|
|
t.Errorf("client_ip field missing")
|
|
}
|
|
if _, ok := fields["error"]; !ok {
|
|
t.Errorf("error field missing")
|
|
}
|
|
}
|
|
|
|
func TestRecoveryMiddleware_PassThroughNormalRequest(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
r := gin.New()
|
|
r.Use(RecoveryMiddleware())
|
|
r.GET("/ok", func(c *gin.Context) {
|
|
c.Status(http.StatusOK)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/ok", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestRecoveryMiddleware_BrokenPipeNeverWrites500 覆盖 4 种客户端断开 panic:
|
|
// http.ErrAbortHandler / syscall.EPIPE / syscall.ECONNRESET / *net.OpError(包了 EPIPE)。
|
|
// 这些都必须走 warn 路径,绝不写 500 body——给死 socket 写 500 是浪费,且日志告警会失真。
|
|
func TestRecoveryMiddleware_BrokenPipeNeverWrites500(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
panicValue interface{}
|
|
}{
|
|
{"http.ErrAbortHandler", http.ErrAbortHandler},
|
|
{"syscall.EPIPE", syscall.EPIPE},
|
|
{"syscall.ECONNRESET", syscall.ECONNRESET},
|
|
{"wrapped EPIPE via net.OpError", &net.OpError{Op: "write", Net: "tcp", Err: syscall.EPIPE}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
observed, restore := captureLogs(t)
|
|
defer restore()
|
|
|
|
r := gin.New()
|
|
r.Use(RequestIDMiddleware())
|
|
r.Use(RecoveryMiddleware())
|
|
panicValue := tc.panicValue
|
|
r.GET("/abort", func(c *gin.Context) {
|
|
panic(panicValue)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/abort", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code == http.StatusInternalServerError {
|
|
t.Fatalf("broken-pipe should not return 500, got status=%d body=%q", w.Code, w.Body.String())
|
|
}
|
|
|
|
warns := observed.FilterMessage("client_disconnected_panic").All()
|
|
if len(warns) != 1 {
|
|
t.Fatalf("expected 1 client_disconnected_panic entry, got %d", len(warns))
|
|
}
|
|
if errs := observed.FilterMessage("panic_recovered").All(); len(errs) != 0 {
|
|
t.Fatalf("broken-pipe should not log panic_recovered, got %d entries", len(errs))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRecoveryMiddleware_PanicValueTypes 覆盖 panic(string/int/error) 三种值类型,
|
|
// 验证 normalizePanic 都能转成可日志化的 error,且都走 error 路径。
|
|
func TestRecoveryMiddleware_PanicValueTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
panicValue interface{}
|
|
}{
|
|
{"string panic", "simulated panic"},
|
|
{"int panic", 42},
|
|
{"error panic", errors.New("typed error panic")},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
observed, restore := captureLogs(t)
|
|
defer restore()
|
|
|
|
r := gin.New()
|
|
r.Use(RequestIDMiddleware())
|
|
r.Use(RecoveryMiddleware())
|
|
panicValue := tc.panicValue
|
|
r.GET("/boom", func(c *gin.Context) {
|
|
panic(panicValue)
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d", w.Code)
|
|
}
|
|
if len(observed.FilterMessage("panic_recovered").All()) != 1 {
|
|
t.Fatalf("expected 1 panic_recovered entry, got %d", len(observed.All()))
|
|
}
|
|
})
|
|
}
|
|
}
|