Files
xlgo-core/middleware/logger_internal_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

263 lines
8.1 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 middleware
import (
"bytes"
"io"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// newCtxWithBody 构造一个带请求体的 gin.Context,仅用于 logger 内部测试。
func newCtxWithBody(body []byte) *gin.Context {
req := httptest.NewRequest("POST", "/", bytes.NewReader(body))
c := &gin.Context{}
c.Request = req
return c
}
// TestReadBodyBounded_TruncatesLogCopy 复现 H3:日志副本必须封顶到 maxLen,
// 而不是把整个 body 读入内存后再截断(原 io.ReadAll 无上限 → OOM)。
func TestReadBodyBounded_TruncatesLogCopy(t *testing.T) {
cases := []struct {
name string
bodyLen int
maxLen int
}{
{"smaller_than_limit", 100, 1024},
{"equal_to_limit", 1024, 1024},
{"larger_than_limit", 100_000, 1024},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
body := bytes.Repeat([]byte("a"), tc.bodyLen)
c := newCtxWithBody(body)
got := readBodyBounded(c, tc.maxLen)
want := tc.bodyLen
if want > tc.maxLen {
want = tc.maxLen
}
if len(got) != want {
t.Fatalf("log copy len = %d, want %d (maxLen=%d, bodyLen=%d)", len(got), want, tc.maxLen, tc.bodyLen)
}
})
}
}
// TestReadBodyBounded_RestoresFullBody 是 H3 修复的闭环断言:
// 即使日志副本被截断,下游处理器仍必须能读到完整请求体(io.MultiReader 复原)。
func TestReadBodyBounded_RestoresFullBody(t *testing.T) {
const maxLen = 64
body := []byte(strings.Repeat("ABCDEFGH", 1000)) // 8000 字节,远超 maxLen
c := newCtxWithBody(body)
logCopy := readBodyBounded(c, maxLen)
// 日志副本封顶
if len(logCopy) != maxLen {
t.Fatalf("log copy len = %d, want %d", len(logCopy), maxLen)
}
// 下游必须拿到完整原始 body
restored, err := io.ReadAll(c.Request.Body)
if err != nil {
t.Fatalf("read restored body: %v", err)
}
if !bytes.Equal(restored, body) {
t.Fatalf("downstream body corrupted: got len=%d, want len=%d", len(restored), len(body))
}
}
// TestReadBodyBounded_PreservesSmallBodyExactly 小于上限时日志副本与原始一致。
func TestReadBodyBounded_PreservesSmallBodyExactly(t *testing.T) {
body := []byte(`{"user":"alice","password":"secret"}`)
c := newCtxWithBody(body)
got := readBodyBounded(c, 1024)
if !bytes.Equal(got, body) {
t.Fatalf("log copy = %q, want %q", got, body)
}
restored, _ := io.ReadAll(c.Request.Body)
if !bytes.Equal(restored, body) {
t.Fatalf("downstream body = %q, want %q", restored, body)
}
}
// TestBodyLogWriter_Bounded 复现 H3 响应侧:响应体捕获缓冲区必须封顶,
// 完整响应仍写入下游 ResponseWriter。
func TestBodyLogWriter_Bounded(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 32
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
large := bytes.Repeat([]byte("Z"), 10_000)
n, err := w.Write(large)
if err != nil {
t.Fatalf("write: %v", err)
}
if n != len(large) {
t.Fatalf("Write returned %d, want %d (下游必须收到完整响应)", n, len(large))
}
if w.body.Len() > maxLen {
t.Fatalf("captured buffer len = %d, must be <= %d (OOM 防护失效)", w.body.Len(), maxLen)
}
if w.body.Len() != maxLen {
t.Fatalf("captured buffer len = %d, want exactly %d", w.body.Len(), maxLen)
}
// 下游 ResponseWriter 收到完整内容
if rec.Body.Len() != len(large) {
t.Fatalf("downstream response len = %d, want %d", rec.Body.Len(), len(large))
}
}
// TestBodyLogWriter_NoLimit maxLen<=0 时不限制(向后兼容)。
func TestBodyLogWriter_NoLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: 0,
}
data := []byte("hello world")
if _, err := w.Write(data); err != nil {
t.Fatalf("write: %v", err)
}
if !bytes.Equal(w.body.Bytes(), data) {
t.Fatalf("captured = %q, want %q", w.body.Bytes(), data)
}
}
// TestBodyLogWriter_MultiWriteAccumulation 多次小写累积不超过 maxLen,
// 下游仍收到完整拼接结果。
func TestBodyLogWriter_MultiWriteAccumulation(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 32
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
chunks := [][]byte{[]byte("0123456789"), []byte("abcdefghij"), []byte("ABCDEFGHIJ"), []byte("zzzzzzzzzz")}
var downstream bytes.Buffer
for _, ch := range chunks {
n, err := w.Write(ch)
if err != nil || n != len(ch) {
t.Fatalf("Write chunk %q: n=%d err=%v", ch, n, err)
}
downstream.Write(ch)
}
if w.body.Len() > maxLen {
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
}
if w.body.Len() != maxLen {
t.Fatalf("captured len = %d, want exactly %d", w.body.Len(), maxLen)
}
if !bytes.Equal(rec.Body.Bytes(), downstream.Bytes()) {
t.Fatalf("downstream = %q, want %q", rec.Body.Bytes(), downstream.Bytes())
}
}
// TestBodyLogWriter_WriteStringBounded WriteString 路径同样封顶。
func TestBodyLogWriter_WriteStringBounded(t *testing.T) {
gin.SetMode(gin.TestMode)
const maxLen = 16
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
w := &bodyLogWriter{
ResponseWriter: c.Writer,
body: new(bytes.Buffer),
maxLen: maxLen,
}
large := strings.Repeat("S", 500)
n, err := w.WriteString(large)
if err != nil {
t.Fatalf("WriteString: %v", err)
}
if n != len(large) {
t.Fatalf("WriteString returned %d, want %d", n, len(large))
}
if w.body.Len() > maxLen {
t.Fatalf("captured len = %d, must be <= %d", w.body.Len(), maxLen)
}
if rec.Body.Len() != len(large) {
t.Fatalf("downstream len = %d, want %d", rec.Body.Len(), len(large))
}
}
// TestFilterSensitiveQuery P1 #14query 中的敏感参数值必须脱敏,非敏感原样保留。
func TestFilterSensitiveQuery(t *testing.T) {
cases := []struct {
name string
in string
contains string // 期望结果包含
absent string // 期望结果不含(敏感原值)
}{
{"empty", "", "", ""},
{"no sensitive", "page=1&size=20", "page=1", ""},
{"access_token redacted", "access_token=supersecret123&page=1", "FILTERED", "supersecret123"},
{"password redacted", "user=alice&password=hunter2", "FILTERED", "hunter2"},
{"case insensitive key", "Token=abc.def.ghi", "FILTERED", "abc.def.ghi"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := filterSensitiveQuery(tc.in)
if tc.contains != "" && !strings.Contains(got, tc.contains) {
t.Errorf("filterSensitiveQuery(%q) = %q, want contains %q", tc.in, got, tc.contains)
}
if tc.absent != "" && strings.Contains(got, tc.absent) {
t.Errorf("filterSensitiveQuery(%q) = %q, must NOT contain sensitive value %q", tc.in, got, tc.absent)
}
})
}
}
// TestLoggerWithConfig_NormalizesMaxBodyLength MaxBodyLength<=0 时归一化为默认值,
// 确保响应侧捕获缓冲区仍有上限(H3 复审 MEDIUM:消除请求/响应侧 maxLen<=0 不对称)。
func TestLoggerWithConfig_NormalizesMaxBodyLength(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
cfg := LoggerConfig{
LogRequestBody: true,
LogResponseBody: true,
MaxBodyLength: 0, // 手滑置 0,应被归一化为默认 1024
SkipPaths: []string{},
SkipPathPrefixes: []string{},
SlowRequestThreshold: 500 * time.Millisecond,
}
r.Use(LoggerWithConfig(cfg))
r.POST("/", func(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
c.Data(200, "text/plain", body)
})
// 请求体 5000 字节(> 默认 1024),下游应仍得完整 body
reqBody := bytes.Repeat([]byte("a"), 5000)
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/", bytes.NewReader(reqBody))
r.ServeHTTP(w, req)
if w.Body.Len() != len(reqBody) {
t.Fatalf("downstream body len = %d, want %d (请求体必须完整复原)", w.Body.Len(), len(reqBody))
}
if !bytes.Equal(w.Body.Bytes(), reqBody) {
t.Fatalf("downstream body corrupted")
}
}