|
|
|
@@ -1,13 +1,13 @@ |
|
|
|
package middleware |
|
|
|
|
|
|
|
import ( |
|
|
|
"fmt" |
|
|
|
"io" |
|
|
|
"bytes" |
|
|
|
"net/http" |
|
|
|
"net/http/httptest" |
|
|
|
"os" |
|
|
|
"path/filepath" |
|
|
|
"strings" |
|
|
|
"testing" |
|
|
|
"time" |
|
|
|
|
|
|
|
"github.com/gin-gonic/gin" |
|
|
|
"github.com/stretchr/testify/assert" |
|
|
|
@@ -67,17 +67,15 @@ func TestGetCaptureEnabledUsersCount(t *testing.T) { |
|
|
|
SetCaptureEnabled(2, false) |
|
|
|
} |
|
|
|
|
|
|
|
// --- captureResponseWriter 测试 --- |
|
|
|
|
|
|
|
func TestCaptureResponseWriter_Write(t *testing.T) { |
|
|
|
gin.SetMode(gin.TestMode) |
|
|
|
rec := httptest.NewRecorder() |
|
|
|
c, _ := gin.CreateTestContext(rec) |
|
|
|
ch := make(chan []byte, 16) |
|
|
|
collector := newResponseCaptureCollector() |
|
|
|
|
|
|
|
cw := &captureResponseWriter{ |
|
|
|
ResponseWriter: c.Writer, |
|
|
|
ch: ch, |
|
|
|
collector: collector, |
|
|
|
} |
|
|
|
|
|
|
|
n, err := cw.Write([]byte("hello")) |
|
|
|
@@ -85,23 +83,19 @@ func TestCaptureResponseWriter_Write(t *testing.T) { |
|
|
|
assert.Equal(t, 5, n) |
|
|
|
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) { |
|
|
|
gin.SetMode(gin.TestMode) |
|
|
|
rec := httptest.NewRecorder() |
|
|
|
c, _ := gin.CreateTestContext(rec) |
|
|
|
ch := make(chan []byte, 16) |
|
|
|
collector := newResponseCaptureCollector() |
|
|
|
|
|
|
|
cw := &captureResponseWriter{ |
|
|
|
ResponseWriter: c.Writer, |
|
|
|
ch: ch, |
|
|
|
collector: collector, |
|
|
|
} |
|
|
|
|
|
|
|
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, "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) { |
|
|
|
f, err := createCaptureFile("test-req-123") |
|
|
|
require.NoError(t, err) |
|
|
|
defer os.RemoveAll(filepath.Dir(f.Name())) |
|
|
|
defer f.Close() |
|
|
|
|
|
|
|
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()) |
|
|
|
info, err := os.Stat(dir) |
|
|
|
require.NoError(t, err) |
|
|
|
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) |
|
|
|
w := httptest.NewRecorder() |
|
|
|
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("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) |
|
|
|
} |