package middleware import ( "bytes" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" "github.com/QuantumNous/new-api/common" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func setupCaptureTestRouter() *gin.Engine { gin.SetMode(gin.TestMode) return gin.New() } const captureTestDir = "./data/relay-capture" type relayCaptureTestRecord struct { RequestID string `json:"request_id"` CapturedAt string `json:"captured_at"` DurationMs int64 `json:"duration_ms"` UserID int64 `json:"user_id"` Request struct { Method string `json:"method"` Path string `json:"path"` Query string `json:"query"` Headers map[string][]string `json:"headers"` Body string `json:"body"` } `json:"request"` Response struct { StatusCode int `json:"status_code"` Headers map[string][]string `json:"headers"` Body string `json:"body"` BodyChunks []string `json:"body_chunks"` } `json:"response"` CaptureErrors []string `json:"capture_errors"` } func readCaptureRecord(t *testing.T, requestID string) relayCaptureTestRecord { t.Helper() today := time.Now().Format("2006-01-02") path := filepath.Join(captureTestDir, today, requestID+".json") content, err := os.ReadFile(path) require.NoError(t, err) require.NotContains(t, string(content), "=== REQUEST") require.NotContains(t, string(content), "=== RESPONSE") require.NotContains(t, string(content), "=== END") var record relayCaptureTestRecord require.NoError(t, common.Unmarshal(content, &record)) return record } func readLatestCaptureRecord(t *testing.T) relayCaptureTestRecord { t.Helper() today := time.Now().Format("2006-01-02") dateDir := filepath.Join(captureTestDir, today) files, err := os.ReadDir(dateDir) require.NoError(t, err) require.NotEmpty(t, files, "should have capture json files") latest := filepath.Join(dateDir, files[len(files)-1].Name()) require.True(t, strings.HasSuffix(latest, ".json")) content, err := os.ReadFile(latest) require.NoError(t, err) var record relayCaptureTestRecord require.NoError(t, common.Unmarshal(content, &record)) return record } func TestRelayCapture_NonStreaming_EnabledUser(t *testing.T) { clearCaptureCache() t.Cleanup(func() { os.RemoveAll(captureTestDir) }) SetCaptureEnabled(42, true) defer SetCaptureEnabled(42, false) requestID := fmt.Sprintf("test-non-stream-%d", time.Now().UnixNano()) router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set("id", 42) c.Set(common.RequestIdKey, requestID) c.Next() }) router.Use(RelayCaptureMiddleware()) router.POST("/v1/chat/completions", func(c *gin.Context) { c.JSON(200, gin.H{ "id": "chatcmpl-test", "object": "chat.completion", "choices": []gin.H{{"message": gin.H{"role": "assistant", "content": "Hello!"}}}, }) }) body := `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}` req, _ := http.NewRequest("POST", "/v1/chat/completions", bytes.NewReader([]byte(body))) req.Header.Set("Authorization", "Bearer sk-test") req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) record := readCaptureRecord(t, requestID) assert.Equal(t, requestID, record.RequestID) assert.NotEmpty(t, record.CapturedAt) assert.GreaterOrEqual(t, record.DurationMs, int64(0)) assert.Equal(t, int64(42), record.UserID) assert.Equal(t, "POST", record.Request.Method) assert.Equal(t, "/v1/chat/completions", record.Request.Path) assert.Equal(t, body, record.Request.Body) assert.Equal(t, []string{"[REDACTED]"}, record.Request.Headers["Authorization"]) assert.Equal(t, []string{"application/json"}, record.Request.Headers["Content-Type"]) assert.Equal(t, 200, record.Response.StatusCode) assert.Contains(t, record.Response.Headers["Content-Type"][0], "application/json") assert.Contains(t, record.Response.Body, "chatcmpl-test") assert.NotEmpty(t, record.Response.BodyChunks) assert.Empty(t, record.CaptureErrors) } func TestRelayCapture_Streaming_EnabledUser(t *testing.T) { clearCaptureCache() t.Cleanup(func() { os.RemoveAll(captureTestDir) }) SetCaptureEnabled(42, true) defer SetCaptureEnabled(42, false) requestID := fmt.Sprintf("test-stream-%d", time.Now().UnixNano()) router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set("id", 42) c.Set(common.RequestIdKey, requestID) c.Next() }) router.Use(RelayCaptureMiddleware()) router.POST("/v1/chat/completions", func(c *gin.Context) { c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") flusher := c.Writer.(http.Flusher) chunks := []string{ "data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\"Hello \"}}]}\n\n", "data: {\"id\":\"1\",\"choices\":[{\"delta\":{\"content\":\"world!\"}}]}\n\n", "data: [DONE]\n\n", } for _, chunk := range chunks { _, _ = c.Writer.Write([]byte(chunk)) flusher.Flush() time.Sleep(5 * time.Millisecond) } }) body := `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}` req, _ := http.NewRequest("POST", "/v1/chat/completions", bytes.NewReader([]byte(body))) req.Header.Set("Authorization", "Bearer sk-test") req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) record := readCaptureRecord(t, requestID) assert.Equal(t, requestID, record.RequestID) assert.Equal(t, "POST", record.Request.Method) assert.Equal(t, "/v1/chat/completions", record.Request.Path) assert.Equal(t, body, record.Request.Body) assert.Equal(t, 200, record.Response.StatusCode) assert.Contains(t, record.Response.Body, "Hello ") assert.Contains(t, record.Response.Body, "world!") assert.Contains(t, record.Response.Body, "[DONE]") require.Len(t, record.Response.BodyChunks, 3) assert.Contains(t, record.Response.BodyChunks[0], "Hello ") assert.Contains(t, record.Response.BodyChunks[1], "world!") assert.Contains(t, record.Response.BodyChunks[2], "[DONE]") assert.Empty(t, record.CaptureErrors) } func TestRelayCapture_RedactsSensitiveRequestHeaders(t *testing.T) { clearCaptureCache() t.Cleanup(func() { os.RemoveAll(captureTestDir) }) SetCaptureEnabled(42, true) defer SetCaptureEnabled(42, false) requestID := fmt.Sprintf("test-redaction-%d", time.Now().UnixNano()) router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set("id", 42) c.Set(common.RequestIdKey, requestID) c.Next() }) router.Use(RelayCaptureMiddleware()) router.POST("/v1/chat/completions", func(c *gin.Context) { c.JSON(200, gin.H{"result": "ok"}) }) req, _ := http.NewRequest("POST", "/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4"}`))) req.Header.Set("Authorization", "Bearer sk-test") req.Header.Set("X-Api-Key", "x-secret") req.Header.Set("Cookie", "session=secret") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) today := time.Now().Format("2006-01-02") path := filepath.Join(captureTestDir, today, requestID+".json") content, err := os.ReadFile(path) require.NoError(t, err) contentStr := string(content) assert.Contains(t, contentStr, "[REDACTED]") assert.NotContains(t, contentStr, "Bearer sk-test") assert.NotContains(t, contentStr, "x-secret") assert.NotContains(t, contentStr, "session=secret") } func TestRelayCapture_DisabledUser(t *testing.T) { clearCaptureCache() router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set("id", 99) c.Set(common.RequestIdKey, fmt.Sprintf("test-disabled-%d", time.Now().UnixNano())) c.Next() }) router.Use(RelayCaptureMiddleware()) router.POST("/v1/chat/completions", func(c *gin.Context) { c.JSON(200, gin.H{"result": "ok"}) }) req, _ := http.NewRequest("POST", "/v1/chat/completions", bytes.NewReader([]byte(`{}`))) req.Header.Set("Authorization", "Bearer sk-test") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) today := time.Now().Format("2006-01-02") dateDir := filepath.Join(captureTestDir, today) if _, err := os.Stat(dateDir); err == nil { files, _ := os.ReadDir(dateDir) for _, f := range files { assert.NotContains(t, f.Name(), "test-disabled", "disabled user should not generate capture files") } } } func TestRelayCapture_LargeStream(t *testing.T) { clearCaptureCache() t.Cleanup(func() { os.RemoveAll(captureTestDir) }) SetCaptureEnabled(42, true) defer SetCaptureEnabled(42, false) requestID := fmt.Sprintf("test-large-%d", time.Now().UnixNano()) router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set("id", 42) c.Set(common.RequestIdKey, requestID) c.Next() }) router.Use(RelayCaptureMiddleware()) router.POST("/v1/chat/completions", func(c *gin.Context) { c.Header("Content-Type", "text/event-stream") flusher := c.Writer.(http.Flusher) for i := 0; i < 50; i++ { chunk := fmt.Sprintf("data: {\"id\":\"%d\",\"choices\":[{\"delta\":{\"content\":\"chunk%d \"}}]}\n\n", i, i) _, _ = c.Writer.Write([]byte(chunk)) flusher.Flush() } _, _ = c.Writer.Write([]byte("data: [DONE]\n\n")) flusher.Flush() }) req, _ := http.NewRequest("POST", "/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4","stream":true}`))) req.Header.Set("Authorization", "Bearer sk-test") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) record := readCaptureRecord(t, requestID) assert.Contains(t, record.Response.Body, "chunk0") assert.Contains(t, record.Response.Body, "chunk49") assert.Contains(t, record.Response.Body, "[DONE]") assert.Len(t, record.Response.BodyChunks, 51) } func TestRelayCapture_GetRequest(t *testing.T) { clearCaptureCache() t.Cleanup(func() { os.RemoveAll(captureTestDir) }) SetCaptureEnabled(42, true) defer SetCaptureEnabled(42, false) requestID := fmt.Sprintf("test-get-%d", time.Now().UnixNano()) router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set("id", 42) c.Set(common.RequestIdKey, requestID) c.Next() }) router.Use(RelayCaptureMiddleware()) router.GET("/v1/models", func(c *gin.Context) { c.JSON(200, gin.H{ "data": []gin.H{{"id": "gpt-4", "object": "model"}}, }) }) req, _ := http.NewRequest("GET", "/v1/models", nil) req.Header.Set("Authorization", "Bearer sk-test") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) record := readCaptureRecord(t, requestID) assert.Equal(t, "GET", record.Request.Method) assert.Equal(t, "/v1/models", record.Request.Path) assert.Equal(t, "", record.Request.Body) assert.Equal(t, 200, record.Response.StatusCode) assert.Contains(t, record.Response.Body, "gpt-4") } func TestRelayCapture_ZeroUserID(t *testing.T) { clearCaptureCache() router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set(common.RequestIdKey, fmt.Sprintf("test-zero-%d", time.Now().UnixNano())) c.Next() }) router.Use(RelayCaptureMiddleware()) router.GET("/v1/models", func(c *gin.Context) { c.JSON(200, gin.H{"data": []gin.H{}}) }) req, _ := http.NewRequest("GET", "/v1/models", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) } func TestRelayCapture_EmptyRequestIDUsesFallbackFilename(t *testing.T) { clearCaptureCache() t.Cleanup(func() { os.RemoveAll(captureTestDir) }) SetCaptureEnabled(42, true) defer SetCaptureEnabled(42, false) router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set("id", 42) c.Next() }) router.Use(RelayCaptureMiddleware()) router.GET("/v1/models", func(c *gin.Context) { c.JSON(200, gin.H{"data": []gin.H{}}) }) req, _ := http.NewRequest("GET", "/v1/models", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) today := time.Now().Format("2006-01-02") dateDir := filepath.Join(captureTestDir, today) files, err := os.ReadDir(dateDir) require.NoError(t, err) require.Len(t, files, 1) assert.True(t, strings.HasPrefix(files[0].Name(), "capture-")) assert.True(t, strings.HasSuffix(files[0].Name(), ".json")) record := readLatestCaptureRecord(t) assert.True(t, strings.HasPrefix(record.RequestID, "capture-")) assert.Equal(t, "GET", record.Request.Method) assert.Equal(t, "/v1/models", record.Request.Path) } func TestRelayCapture_ResponseUnchanged(t *testing.T) { clearCaptureCache() t.Cleanup(func() { os.RemoveAll(captureTestDir) }) SetCaptureEnabled(42, true) defer SetCaptureEnabled(42, false) router := setupCaptureTestRouter() router.Use(func(c *gin.Context) { c.Set("id", 42) c.Set(common.RequestIdKey, fmt.Sprintf("test-unchanged-%d", time.Now().UnixNano())) c.Next() }) router.Use(RelayCaptureMiddleware()) router.POST("/v1/chat/completions", func(c *gin.Context) { c.JSON(200, gin.H{ "id": "chatcmpl-123", "choices": []gin.H{{"message": gin.H{"content": "exact response"}}}, }) }) req, _ := http.NewRequest("POST", "/v1/chat/completions", bytes.NewReader([]byte(`{"model":"gpt-4"}`))) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) assert.Contains(t, rec.Body.String(), "exact response") assert.Contains(t, rec.Body.String(), "chatcmpl-123") } func TestRelayCapture_ConcurrentRequests(t *testing.T) { clearCaptureCache() t.Cleanup(func() { os.RemoveAll(captureTestDir) }) SetCaptureEnabled(42, true) defer SetCaptureEnabled(42, false) router := setupCaptureTestRouter() var reqCounter int64 router.Use(func(c *gin.Context) { c.Set("id", 42) n := atomic.AddInt64(&reqCounter, 1) c.Set(common.RequestIdKey, fmt.Sprintf("concurrent-%d-%d", n, time.Now().UnixNano())) c.Next() }) router.Use(RelayCaptureMiddleware()) router.POST("/v1/chat/completions", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) done := make(chan bool, 10) for i := 0; i < 10; i++ { go func(idx int) { req, _ := http.NewRequest("POST", "/v1/chat/completions", bytes.NewReader([]byte(fmt.Sprintf(`{"req":%d}`, idx)))) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) assert.Equal(t, 200, rec.Code) done <- true }(i) } for i := 0; i < 10; i++ { select { case <-done: case <-time.After(10 * time.Second): t.Fatal("timeout waiting for concurrent request") } } time.Sleep(100 * time.Millisecond) today := time.Now().Format("2006-01-02") dateDir := filepath.Join(captureTestDir, today) files, err := os.ReadDir(dateDir) require.NoError(t, err) assert.Equal(t, 10, len(files), "should have 10 capture json files") for _, f := range files { require.True(t, strings.HasSuffix(f.Name(), ".json")) content, err := os.ReadFile(filepath.Join(dateDir, f.Name())) require.NoError(t, err) var record relayCaptureTestRecord require.NoError(t, common.Unmarshal(content, &record)) assert.Equal(t, "POST", record.Request.Method) assert.Equal(t, "/v1/chat/completions", record.Request.Path) assert.Equal(t, 200, record.Response.StatusCode) assert.Contains(t, record.Response.Body, "ok") } }