| @@ -1,9 +1,12 @@ | |||||
| package middleware | package middleware | ||||
| import ( | import ( | ||||
| "bytes" | |||||
| "fmt" | "fmt" | ||||
| "net/http" | |||||
| "os" | "os" | ||||
| "path/filepath" | "path/filepath" | ||||
| "strings" | |||||
| "sync" | "sync" | ||||
| "time" | "time" | ||||
| @@ -27,7 +30,6 @@ func SetCaptureEnabled(userID int64, enabled bool) { | |||||
| } | } | ||||
| } | } | ||||
| // LoadCaptureEnabledUsers 从数据库加载 capture_relay=true 的用户到内存缓存 | |||||
| func LoadCaptureEnabledUsers() error { | func LoadCaptureEnabledUsers() error { | ||||
| var ids []int64 | var ids []int64 | ||||
| if err := model.DB.Model(&model.User{}). | if err := model.DB.Model(&model.User{}). | ||||
| @@ -41,7 +43,6 @@ func LoadCaptureEnabledUsers() error { | |||||
| return nil | return nil | ||||
| } | } | ||||
| // GetCaptureEnabledUsersCount 仅用于测试 | |||||
| func GetCaptureEnabledUsersCount() int { | func GetCaptureEnabledUsersCount() int { | ||||
| count := 0 | count := 0 | ||||
| captureEnabledUsers.Range(func(key, value interface{}) bool { | captureEnabledUsers.Range(func(key, value interface{}) bool { | ||||
| @@ -51,19 +52,80 @@ func GetCaptureEnabledUsersCount() int { | |||||
| return count | return count | ||||
| } | } | ||||
| // --- captureResponseWriter --- | |||||
| type relayCaptureRecord struct { | |||||
| RequestID string `json:"request_id"` | |||||
| CapturedAt string `json:"captured_at"` | |||||
| DurationMs int64 `json:"duration_ms"` | |||||
| UserID int64 `json:"user_id"` | |||||
| Request relayCaptureRequestRecord `json:"request"` | |||||
| Response relayCaptureResponseRecord `json:"response"` | |||||
| CaptureErrors []string `json:"capture_errors"` | |||||
| } | |||||
| type relayCaptureRequestRecord struct { | |||||
| Method string `json:"method"` | |||||
| Path string `json:"path"` | |||||
| Query string `json:"query"` | |||||
| Headers map[string][]string `json:"headers"` | |||||
| Body string `json:"body"` | |||||
| } | |||||
| type relayCaptureResponseRecord struct { | |||||
| StatusCode int `json:"status_code"` | |||||
| Headers map[string][]string `json:"headers"` | |||||
| Body string `json:"body"` | |||||
| BodyChunks []string `json:"body_chunks"` | |||||
| } | |||||
| type responseCaptureCollector struct { | |||||
| body bytes.Buffer | |||||
| chunks []string | |||||
| } | |||||
| func newResponseCaptureCollector() *responseCaptureCollector { | |||||
| return &responseCaptureCollector{ | |||||
| chunks: make([]string, 0), | |||||
| } | |||||
| } | |||||
| func (collector *responseCaptureCollector) Add(chunk []byte) { | |||||
| if len(chunk) == 0 { | |||||
| return | |||||
| } | |||||
| copied := make([]byte, len(chunk)) | |||||
| copy(copied, chunk) | |||||
| collector.body.Write(copied) | |||||
| collector.chunks = append(collector.chunks, string(copied)) | |||||
| } | |||||
| func (collector *responseCaptureCollector) Body() string { | |||||
| return collector.body.String() | |||||
| } | |||||
| func (collector *responseCaptureCollector) Chunks() []string { | |||||
| chunks := make([]string, len(collector.chunks)) | |||||
| copy(chunks, collector.chunks) | |||||
| return chunks | |||||
| } | |||||
| type captureResponseWriter struct { | type captureResponseWriter struct { | ||||
| gin.ResponseWriter | gin.ResponseWriter | ||||
| ch chan<- []byte | |||||
| collector *responseCaptureCollector | |||||
| ch chan<- []byte | |||||
| } | } | ||||
| func (cw *captureResponseWriter) Write(b []byte) (int, error) { | func (cw *captureResponseWriter) Write(b []byte) (int, error) { | ||||
| n, err := cw.ResponseWriter.Write(b) | n, err := cw.ResponseWriter.Write(b) | ||||
| if n > 0 { | if n > 0 { | ||||
| buf := make([]byte, n) | |||||
| copy(buf, b[:n]) | |||||
| cw.ch <- buf | |||||
| chunk := b[:n] | |||||
| if cw.collector != nil { | |||||
| cw.collector.Add(chunk) | |||||
| } | |||||
| if cw.ch != nil { | |||||
| buf := make([]byte, n) | |||||
| copy(buf, chunk) | |||||
| cw.ch <- buf | |||||
| } | |||||
| } | } | ||||
| return n, err | return n, err | ||||
| } | } | ||||
| @@ -71,16 +133,19 @@ func (cw *captureResponseWriter) Write(b []byte) (int, error) { | |||||
| func (cw *captureResponseWriter) WriteString(s string) (int, error) { | func (cw *captureResponseWriter) WriteString(s string) (int, error) { | ||||
| n, err := cw.ResponseWriter.WriteString(s) | n, err := cw.ResponseWriter.WriteString(s) | ||||
| if n > 0 { | if n > 0 { | ||||
| buf := make([]byte, n) | |||||
| copy(buf, s[:n]) | |||||
| cw.ch <- buf | |||||
| chunk := s[:n] | |||||
| if cw.collector != nil { | |||||
| cw.collector.Add([]byte(chunk)) | |||||
| } | |||||
| if cw.ch != nil { | |||||
| buf := make([]byte, n) | |||||
| copy(buf, chunk) | |||||
| cw.ch <- buf | |||||
| } | |||||
| } | } | ||||
| return n, err | return n, err | ||||
| } | } | ||||
| // --- startCaptureWriter --- | |||||
| // startCaptureWriter 启动单个 writer goroutine,从 chIn 顺序写入文件 | |||||
| func startCaptureWriter(f *os.File, requestID string, start time.Time) (in chan<- []byte, done <-chan struct{}) { | func startCaptureWriter(f *os.File, requestID string, start time.Time) (in chan<- []byte, done <-chan struct{}) { | ||||
| chIn := make(chan []byte, 128) | chIn := make(chan []byte, 128) | ||||
| doneCh := make(chan struct{}) | doneCh := make(chan struct{}) | ||||
| @@ -89,15 +154,20 @@ func startCaptureWriter(f *os.File, requestID string, start time.Time) (in chan< | |||||
| defer close(doneCh) | defer close(doneCh) | ||||
| defer f.Close() | defer f.Close() | ||||
| for chunk := range chIn { | for chunk := range chIn { | ||||
| f.Write(chunk) | |||||
| _, _ = f.Write(chunk) | |||||
| } | } | ||||
| fmt.Fprintf(f, "\n=== END duration_ms=%d ===\n", time.Since(start).Milliseconds()) | |||||
| _, _ = fmt.Fprintf(f, "\n=== END duration_ms=%d ===\n", time.Since(start).Milliseconds()) | |||||
| }() | }() | ||||
| return chIn, doneCh | return chIn, doneCh | ||||
| } | } | ||||
| // --- 辅助函数 --- | |||||
| func normalizeCaptureRequestID(requestID string) string { | |||||
| if strings.TrimSpace(requestID) != "" { | |||||
| return requestID | |||||
| } | |||||
| return fmt.Sprintf("capture-%d", time.Now().UnixNano()) | |||||
| } | |||||
| func createCaptureFile(requestID string) (*os.File, error) { | func createCaptureFile(requestID string) (*os.File, error) { | ||||
| dir := filepath.Join("./data", "relay-capture", time.Now().Format("2006-01-02")) | dir := filepath.Join("./data", "relay-capture", time.Now().Format("2006-01-02")) | ||||
| @@ -105,36 +175,94 @@ func createCaptureFile(requestID string) (*os.File, error) { | |||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| return os.OpenFile( | return os.OpenFile( | ||||
| filepath.Join(dir, requestID+".log"), | |||||
| filepath.Join(dir, normalizeCaptureRequestID(requestID)+".json"), | |||||
| os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644, | os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644, | ||||
| ) | ) | ||||
| } | } | ||||
| func isSensitiveCaptureHeader(key string) bool { | |||||
| switch strings.ToLower(key) { | |||||
| case "authorization", "proxy-authorization", "x-api-key", "api-key", "cookie": | |||||
| return true | |||||
| default: | |||||
| return false | |||||
| } | |||||
| } | |||||
| func copyHeadersForCapture(headers http.Header, redactSensitive bool) map[string][]string { | |||||
| copied := make(map[string][]string, len(headers)) | |||||
| for key, values := range headers { | |||||
| copiedValues := make([]string, len(values)) | |||||
| if redactSensitive && isSensitiveCaptureHeader(key) { | |||||
| for i := range copiedValues { | |||||
| copiedValues[i] = "[REDACTED]" | |||||
| } | |||||
| } else { | |||||
| copy(copiedValues, values) | |||||
| } | |||||
| copied[key] = copiedValues | |||||
| } | |||||
| return copied | |||||
| } | |||||
| func buildCaptureRequestRecord(c *gin.Context, captureErrors *[]string) relayCaptureRequestRecord { | |||||
| record := relayCaptureRequestRecord{ | |||||
| Method: c.Request.Method, | |||||
| Path: c.Request.URL.Path, | |||||
| Query: c.Request.URL.RawQuery, | |||||
| Headers: copyHeadersForCapture(c.Request.Header, true), | |||||
| Body: "", | |||||
| } | |||||
| if c.Request.Body == nil { | |||||
| return record | |||||
| } | |||||
| storage, err := common.GetBodyStorage(c) | |||||
| if err != nil { | |||||
| *captureErrors = append(*captureErrors, "failed to read request body: "+err.Error()) | |||||
| return record | |||||
| } | |||||
| body, err := storage.Bytes() | |||||
| if err != nil { | |||||
| *captureErrors = append(*captureErrors, "failed to read request body bytes: "+err.Error()) | |||||
| return record | |||||
| } | |||||
| record.Body = string(body) | |||||
| return record | |||||
| } | |||||
| func buildCaptureResponseRecord(c *gin.Context, collector *responseCaptureCollector) relayCaptureResponseRecord { | |||||
| return relayCaptureResponseRecord{ | |||||
| StatusCode: c.Writer.Status(), | |||||
| Headers: copyHeadersForCapture(c.Writer.Header(), false), | |||||
| Body: collector.Body(), | |||||
| BodyChunks: collector.Chunks(), | |||||
| } | |||||
| } | |||||
| func writeRequestBlock(c *gin.Context, f *os.File, userID int64) { | func writeRequestBlock(c *gin.Context, f *os.File, userID int64) { | ||||
| fmt.Fprintf(f, "=== REQUEST %s ===\n", time.Now().UTC().Format(time.RFC3339Nano)) | |||||
| fmt.Fprintf(f, "%s %s\n", c.Request.Method, c.Request.URL.Path) | |||||
| fmt.Fprintf(f, "user_id: %d\n", userID) | |||||
| _, _ = fmt.Fprintf(f, "=== REQUEST %s ===\n", time.Now().UTC().Format(time.RFC3339Nano)) | |||||
| _, _ = fmt.Fprintf(f, "%s %s\n", c.Request.Method, c.Request.URL.Path) | |||||
| _, _ = fmt.Fprintf(f, "user_id: %d\n", userID) | |||||
| for key, values := range c.Request.Header { | for key, values := range c.Request.Header { | ||||
| for _, v := range values { | for _, v := range values { | ||||
| fmt.Fprintf(f, "%s: %s\n", key, v) | |||||
| _, _ = fmt.Fprintf(f, "%s: %s\n", key, v) | |||||
| } | } | ||||
| } | } | ||||
| fmt.Fprintf(f, "\n") | |||||
| _, _ = fmt.Fprintf(f, "\n") | |||||
| // 读取请求体 | |||||
| if c.Request.Body != nil { | if c.Request.Body != nil { | ||||
| if storage, err := common.GetBodyStorage(c); err == nil { | if storage, err := common.GetBodyStorage(c); err == nil { | ||||
| if body, err := storage.Bytes(); err == nil && len(body) > 0 { | if body, err := storage.Bytes(); err == nil && len(body) > 0 { | ||||
| f.Write(body) | |||||
| _, _ = f.Write(body) | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| fmt.Fprintf(f, "\n") | |||||
| _, _ = fmt.Fprintf(f, "\n") | |||||
| } | } | ||||
| // RelayCaptureMiddleware 抓包中间件 | |||||
| // 对开启 capture_relay 的用户,将请求体和响应体写入本地文件 | |||||
| func RelayCaptureMiddleware() gin.HandlerFunc { | func RelayCaptureMiddleware() gin.HandlerFunc { | ||||
| return func(c *gin.Context) { | return func(c *gin.Context) { | ||||
| userID := int64(c.GetInt("id")) | userID := int64(c.GetInt("id")) | ||||
| @@ -152,11 +280,9 @@ func RelayCaptureMiddleware() gin.HandlerFunc { | |||||
| return | return | ||||
| } | } | ||||
| // 同步写 REQUEST 块 | |||||
| writeRequestBlock(c, f, userID) | writeRequestBlock(c, f, userID) | ||||
| fmt.Fprintf(f, "\n=== RESPONSE ===\n") | |||||
| _, _ = fmt.Fprintf(f, "\n=== RESPONSE ===\n") | |||||
| // 启动 writer goroutine | |||||
| chIn, doneCh := startCaptureWriter(f, requestID, start) | chIn, doneCh := startCaptureWriter(f, requestID, start) | ||||
| c.Writer = &captureResponseWriter{ | c.Writer = &captureResponseWriter{ | ||||
| @@ -166,7 +292,6 @@ func RelayCaptureMiddleware() gin.HandlerFunc { | |||||
| c.Next() | c.Next() | ||||
| // 关闭 chIn,等待 writer 完成(带超时保护) | |||||
| close(chIn) | close(chIn) | ||||
| select { | select { | ||||
| case <-doneCh: | case <-doneCh: | ||||
| @@ -1,13 +1,13 @@ | |||||
| package middleware | package middleware | ||||
| import ( | import ( | ||||
| "fmt" | |||||
| "io" | |||||
| "bytes" | |||||
| "net/http" | |||||
| "net/http/httptest" | "net/http/httptest" | ||||
| "os" | "os" | ||||
| "path/filepath" | "path/filepath" | ||||
| "strings" | |||||
| "testing" | "testing" | ||||
| "time" | |||||
| "github.com/gin-gonic/gin" | "github.com/gin-gonic/gin" | ||||
| "github.com/stretchr/testify/assert" | "github.com/stretchr/testify/assert" | ||||
| @@ -67,17 +67,15 @@ func TestGetCaptureEnabledUsersCount(t *testing.T) { | |||||
| SetCaptureEnabled(2, false) | SetCaptureEnabled(2, false) | ||||
| } | } | ||||
| // --- captureResponseWriter 测试 --- | |||||
| func TestCaptureResponseWriter_Write(t *testing.T) { | func TestCaptureResponseWriter_Write(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| c, _ := gin.CreateTestContext(rec) | c, _ := gin.CreateTestContext(rec) | ||||
| ch := make(chan []byte, 16) | |||||
| collector := newResponseCaptureCollector() | |||||
| cw := &captureResponseWriter{ | cw := &captureResponseWriter{ | ||||
| ResponseWriter: c.Writer, | ResponseWriter: c.Writer, | ||||
| ch: ch, | |||||
| collector: collector, | |||||
| } | } | ||||
| n, err := cw.Write([]byte("hello")) | n, err := cw.Write([]byte("hello")) | ||||
| @@ -85,23 +83,19 @@ func TestCaptureResponseWriter_Write(t *testing.T) { | |||||
| assert.Equal(t, 5, n) | assert.Equal(t, 5, n) | ||||
| assert.Equal(t, "hello", rec.Body.String()) | assert.Equal(t, "hello", rec.Body.String()) | ||||
| select { | |||||
| case data := <-ch: | |||||
| assert.Equal(t, []byte("hello"), data) | |||||
| default: | |||||
| t.Fatal("expected data in channel") | |||||
| } | |||||
| assert.Equal(t, "hello", collector.Body()) | |||||
| assert.Equal(t, []string{"hello"}, collector.Chunks()) | |||||
| } | } | ||||
| func TestCaptureResponseWriter_WriteString(t *testing.T) { | func TestCaptureResponseWriter_WriteString(t *testing.T) { | ||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| c, _ := gin.CreateTestContext(rec) | c, _ := gin.CreateTestContext(rec) | ||||
| ch := make(chan []byte, 16) | |||||
| collector := newResponseCaptureCollector() | |||||
| cw := &captureResponseWriter{ | cw := &captureResponseWriter{ | ||||
| ResponseWriter: c.Writer, | ResponseWriter: c.Writer, | ||||
| ch: ch, | |||||
| collector: collector, | |||||
| } | } | ||||
| n, err := cw.WriteString("test-string") | n, err := cw.WriteString("test-string") | ||||
| @@ -109,86 +103,135 @@ func TestCaptureResponseWriter_WriteString(t *testing.T) { | |||||
| assert.Equal(t, len("test-string"), n) | assert.Equal(t, len("test-string"), n) | ||||
| assert.Equal(t, "test-string", rec.Body.String()) | assert.Equal(t, "test-string", rec.Body.String()) | ||||
| select { | |||||
| case data := <-ch: | |||||
| assert.Equal(t, []byte("test-string"), data) | |||||
| default: | |||||
| t.Fatal("expected data in channel") | |||||
| } | |||||
| assert.Equal(t, "test-string", collector.Body()) | |||||
| assert.Equal(t, []string{"test-string"}, collector.Chunks()) | |||||
| } | } | ||||
| // --- startCaptureWriter 测试 --- | |||||
| func TestStartCaptureWriter_WritesAllChunks(t *testing.T) { | |||||
| f, err := os.CreateTemp("", "test-capture-*.log") | |||||
| require.NoError(t, err) | |||||
| filePath := f.Name() | |||||
| defer os.Remove(filePath) | |||||
| type partialResponseWriter struct { | |||||
| gin.ResponseWriter | |||||
| } | |||||
| start := time.Now() | |||||
| chIn, doneCh := startCaptureWriter(f, "test-req", start) | |||||
| func (w partialResponseWriter) Write(b []byte) (int, error) { | |||||
| _, err := w.ResponseWriter.Write(b[:2]) | |||||
| return 2, err | |||||
| } | |||||
| // 发送 130 个 chunk(大于 channel buffer 128) | |||||
| for i := 0; i < 130; i++ { | |||||
| chIn <- []byte(fmt.Sprintf("data: %d\n", i)) | |||||
| } | |||||
| close(chIn) | |||||
| func TestCaptureResponseWriter_RecordsOnlyWrittenBytes(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| rec := httptest.NewRecorder() | |||||
| c, _ := gin.CreateTestContext(rec) | |||||
| collector := newResponseCaptureCollector() | |||||
| select { | |||||
| case <-doneCh: | |||||
| case <-time.After(5 * time.Second): | |||||
| t.Fatal("timeout waiting for doneCh") | |||||
| cw := &captureResponseWriter{ | |||||
| ResponseWriter: partialResponseWriter{ResponseWriter: c.Writer}, | |||||
| collector: collector, | |||||
| } | } | ||||
| content, err := os.ReadFile(filePath) | |||||
| require.NoError(t, err) | |||||
| contentStr := string(content) | |||||
| assert.Contains(t, contentStr, "data: 0") | |||||
| assert.Contains(t, contentStr, "data: 19") | |||||
| assert.Contains(t, contentStr, "=== END") | |||||
| assert.Contains(t, contentStr, "duration_ms=") | |||||
| n, err := cw.Write([]byte("hello")) | |||||
| assert.NoError(t, err) | |||||
| assert.Equal(t, 2, n) | |||||
| assert.Equal(t, "he", rec.Body.String()) | |||||
| assert.Equal(t, "he", collector.Body()) | |||||
| assert.Equal(t, []string{"he"}, collector.Chunks()) | |||||
| } | } | ||||
| // --- 辅助函数测试 --- | |||||
| func TestCreateCaptureFile(t *testing.T) { | func TestCreateCaptureFile(t *testing.T) { | ||||
| f, err := createCaptureFile("test-req-123") | f, err := createCaptureFile("test-req-123") | ||||
| require.NoError(t, err) | require.NoError(t, err) | ||||
| defer os.RemoveAll(filepath.Dir(f.Name())) | defer os.RemoveAll(filepath.Dir(f.Name())) | ||||
| defer f.Close() | |||||
| assert.Contains(t, f.Name(), "relay-capture") | assert.Contains(t, f.Name(), "relay-capture") | ||||
| assert.Contains(t, f.Name(), "test-req-123.log") | |||||
| assert.Contains(t, f.Name(), "test-req-123.json") | |||||
| assert.NotContains(t, f.Name(), ".log") | |||||
| dir := filepath.Dir(f.Name()) | dir := filepath.Dir(f.Name()) | ||||
| info, err := os.Stat(dir) | info, err := os.Stat(dir) | ||||
| require.NoError(t, err) | require.NoError(t, err) | ||||
| assert.True(t, info.IsDir()) | assert.True(t, info.IsDir()) | ||||
| } | |||||
| func TestNormalizeCaptureRequestID_UsesExistingID(t *testing.T) { | |||||
| requestID := normalizeCaptureRequestID("req-123") | |||||
| assert.Equal(t, "req-123", requestID) | |||||
| } | |||||
| f.Close() | |||||
| func TestNormalizeCaptureRequestID_FallbackForEmptyID(t *testing.T) { | |||||
| requestID := normalizeCaptureRequestID("") | |||||
| assert.True(t, strings.HasPrefix(requestID, "capture-")) | |||||
| assert.False(t, strings.HasSuffix(requestID, ".json")) | |||||
| } | } | ||||
| func TestWriteRequestBlock(t *testing.T) { | |||||
| func TestCopyHeadersForCapture_RedactsSensitiveRequestHeaders(t *testing.T) { | |||||
| headers := http.Header{ | |||||
| "Authorization": []string{"Bearer sk-test"}, | |||||
| "authorization": []string{"Bearer lower"}, | |||||
| "X-Api-Key": []string{"x-key"}, | |||||
| "Api-Key": []string{"api-key"}, | |||||
| "Cookie": []string{"session=secret"}, | |||||
| "Proxy-Authorization": []string{"Basic secret"}, | |||||
| "Content-Type": []string{"application/json"}, | |||||
| "X-Trace-ID": []string{"trace-1"}, | |||||
| } | |||||
| copied := copyHeadersForCapture(headers, true) | |||||
| assert.Equal(t, []string{"[REDACTED]"}, copied["Authorization"]) | |||||
| assert.Equal(t, []string{"[REDACTED]"}, copied["authorization"]) | |||||
| assert.Equal(t, []string{"[REDACTED]"}, copied["X-Api-Key"]) | |||||
| assert.Equal(t, []string{"[REDACTED]"}, copied["Api-Key"]) | |||||
| assert.Equal(t, []string{"[REDACTED]"}, copied["Cookie"]) | |||||
| assert.Equal(t, []string{"[REDACTED]"}, copied["Proxy-Authorization"]) | |||||
| assert.Equal(t, []string{"application/json"}, copied["Content-Type"]) | |||||
| assert.Equal(t, []string{"trace-1"}, copied["X-Trace-ID"]) | |||||
| assert.Equal(t, []string{"Bearer sk-test"}, headers["Authorization"]) | |||||
| } | |||||
| func TestCopyHeadersForCapture_ResponseHeadersNotRedacted(t *testing.T) { | |||||
| headers := http.Header{} | |||||
| headers.Add("Authorization", "response-token") | |||||
| headers.Add("Content-Type", "application/json") | |||||
| copied := copyHeadersForCapture(headers, false) | |||||
| assert.Equal(t, []string{"response-token"}, copied["Authorization"]) | |||||
| assert.Equal(t, []string{"application/json"}, copied["Content-Type"]) | |||||
| } | |||||
| func TestBuildCaptureRequestRecord_PostBody(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | gin.SetMode(gin.TestMode) | ||||
| w := httptest.NewRecorder() | w := httptest.NewRecorder() | ||||
| c, _ := gin.CreateTestContext(w) | c, _ := gin.CreateTestContext(w) | ||||
| c.Set("id", int64(42)) | |||||
| c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) | |||||
| body := `{"model":"gpt-4"}` | |||||
| c.Request = httptest.NewRequest("POST", "/v1/chat/completions?stream=true", bytes.NewReader([]byte(body))) | |||||
| c.Request.Header.Set("Authorization", "Bearer sk-test") | c.Request.Header.Set("Authorization", "Bearer sk-test") | ||||
| c.Request.Header.Set("Content-Type", "application/json") | c.Request.Header.Set("Content-Type", "application/json") | ||||
| f, err := os.CreateTemp("", "test-req-block-*.log") | |||||
| require.NoError(t, err) | |||||
| filePath := f.Name() | |||||
| defer os.Remove(filePath) | |||||
| var captureErrors []string | |||||
| record := buildCaptureRequestRecord(c, &captureErrors) | |||||
| writeRequestBlock(c, f, 42) | |||||
| assert.Equal(t, "POST", record.Method) | |||||
| assert.Equal(t, "/v1/chat/completions", record.Path) | |||||
| assert.Equal(t, "stream=true", record.Query) | |||||
| assert.Equal(t, body, record.Body) | |||||
| assert.Equal(t, []string{"[REDACTED]"}, record.Headers["Authorization"]) | |||||
| assert.Equal(t, []string{"application/json"}, record.Headers["Content-Type"]) | |||||
| assert.Empty(t, captureErrors) | |||||
| } | |||||
| f.Seek(0, io.SeekStart) | |||||
| content, err := io.ReadAll(f) | |||||
| require.NoError(t, err) | |||||
| contentStr := string(content) | |||||
| assert.Contains(t, contentStr, "=== REQUEST") | |||||
| assert.Contains(t, contentStr, "POST /v1/chat/completions") | |||||
| assert.Contains(t, contentStr, "user_id: 42") | |||||
| func TestBuildCaptureRequestRecord_GetNoBody(t *testing.T) { | |||||
| gin.SetMode(gin.TestMode) | |||||
| w := httptest.NewRecorder() | |||||
| c, _ := gin.CreateTestContext(w) | |||||
| c.Request = httptest.NewRequest("GET", "/v1/models", nil) | |||||
| var captureErrors []string | |||||
| record := buildCaptureRequestRecord(c, &captureErrors) | |||||
| assert.Equal(t, "GET", record.Method) | |||||
| assert.Equal(t, "/v1/models", record.Path) | |||||
| assert.Equal(t, "", record.Query) | |||||
| assert.Equal(t, "", record.Body) | |||||
| assert.Empty(t, captureErrors) | |||||
| } | } | ||||