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 #14:query 中的敏感参数值必须脱敏,非敏感原样保留。 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") } }