Files
team-api/relay/common/debug_capture_test.go

242 lines
7.1 KiB
Go
Raw Permalink 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 common
import (
"bytes"
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ===== 脱敏 =====
func TestMaskSecret(t *testing.T) {
cases := []struct {
in, want string
}{
{"short", "****"}, // < 12 全遮蔽
{"12345678901", "****"}, // 11 位仍全遮蔽
{"sk-abcdef1234wxyz", "sk-abc****wxyz"}, // 前 6 后 4
{"", "****"}, // 空值
{"Bearer sk-abcdefghijk", "Bearer****hijk"}, // 带前缀的完整头值
}
for _, c := range cases {
if got := MaskSecret(c.in); got != c.want {
t.Errorf("MaskSecret(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMaskSensitiveHeaders(t *testing.T) {
h := http.Header{}
h.Set("Authorization", "Bearer sk-abcdefghijklmnop")
h.Set("X-Api-Key", "short")
h.Set("Content-Type", "application/json")
h.Add("X-Multi", "a")
h.Add("X-Multi", "b")
out := MaskHeaders(h)
if got := out["Authorization"]; got != "Bearer****mnop" {
t.Errorf("Authorization 未按前6后4脱敏: %q", got)
}
if got := out["X-Api-Key"]; got != "****" {
t.Errorf("短凭证值应全遮蔽: %q", got)
}
if got := out["Content-Type"]; got != "application/json" {
t.Errorf("非敏感头不应改动: %q", got)
}
if got := out["X-Multi"]; got != "a, b" {
t.Errorf("多值头应逗号连接: %q", got)
}
if MaskHeaders(nil) != nil {
t.Error("nil 头应返回 nil")
}
}
func TestMaskURLSecret(t *testing.T) {
raw := "https://example.com/v1/models?key=abcdefghijk1234&x=1"
got := MaskURLSecret(raw)
if strings.Contains(got, "abcdefghijk1234") {
t.Errorf("URL query 凭证未脱敏: %q", got)
}
if !strings.Contains(got, "abcdef****1234") {
t.Errorf("脱敏格式不符合前6后4: %q", got)
}
if !strings.Contains(got, "x=1") {
t.Errorf("非凭证参数不应改动: %q", got)
}
// 无凭证参数的 URL 原样返回
plain := "https://example.com/v1/chat"
if got := MaskURLSecret(plain); got != plain {
t.Errorf("无凭证 URL 被改动: %q", got)
}
}
// ===== body 编码 =====
func TestEncodeBody(t *testing.T) {
// 有效 UTF-8 → plain
data, enc := EncodeBody([]byte(`{"model":"gpt"}`))
if enc != "plain" || data != `{"model":"gpt"}` {
t.Errorf("UTF-8 body 应原样 plain: %q %q", data, enc)
}
// 含 NUL → base64(PG TEXT 不接受 NUL)
nul := []byte{'a', 0x00, 'b'}
data, enc = EncodeBody(nul)
if enc != "base64" {
t.Errorf("含 NUL body 应 base64: %q", enc)
}
if decoded, err := base64Decode(data); err != nil || !bytes.Equal(decoded, nul) {
t.Errorf("base64 往返不一致: %v %v", decoded, err)
}
// 无效 UTF-8 → base64
invalid := []byte{0xff, 0xfe, 0xfd}
if _, enc = EncodeBody(invalid); enc != "base64" {
t.Errorf("无效 UTF-8 body 应 base64: %q", enc)
}
// 空 body
if data, enc = EncodeBody(nil); enc != "plain" || data != "" {
t.Errorf("空 body 应 plain 空串: %q %q", data, enc)
}
}
// ===== DebugClientWriter:不截断 =====
func TestDebugClientWriter(t *testing.T) {
rec := httptest.NewRecorder()
w := NewDebugClientWriter(rec)
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
// 写 2MB,验证完整累积不截断
chunk := bytes.Repeat([]byte("x"), 64*1024)
total := 0
for i := 0; i < 32; i++ {
n, err := w.Write(chunk)
if err != nil || n != len(chunk) {
t.Fatalf("write 失败: n=%d err=%v", n, err)
}
total += n
}
if got := len(w.Bytes()); got != total {
t.Errorf("捕获 %d 字节,期望 %d(不允许截断)", got, total)
}
if w.StatusCode() != 200 {
t.Errorf("状态码 = %d, want 200", w.StatusCode())
}
if ct := w.HeaderSnapshot()["Content-Type"]; ct != "text/event-stream" {
t.Errorf("响应头快照错误: %q", ct)
}
// 未显式 WriteHeader 时默认 200
if code := NewDebugClientWriter(httptest.NewRecorder()).StatusCode(); code != 200 {
t.Errorf("默认状态码 = %d, want 200", code)
}
}
// ===== DebugRoundTripper =====
func TestDebugRoundTripperPassthrough(t *testing.T) {
// ctx 无捕获器:直接透传,行为与裸 transport 一致
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
client := &http.Client{Transport: WrapDebugTransport(http.DefaultTransport.(*http.Transport).Clone())}
resp, err := client.Get(srv.URL)
if err != nil {
t.Fatalf("passthrough 请求失败: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != "ok" {
t.Errorf("透传响应被篡改: %q", body)
}
}
func TestDebugRoundTripperCapture(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 分块写入模拟流式响应,验证 tee 完整镜像
_, _ = w.Write([]byte(`{"chunk":1}`))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
_, _ = w.Write([]byte(`{"chunk":2}`))
}))
defer srv.Close()
a := &DebugAttemptCapture{}
ctx := WithDebugAttempt(context.Background(), a)
client := &http.Client{Transport: WrapDebugTransport(http.DefaultTransport.(*http.Transport).Clone())}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/v1/chat?key=topsecret12345",
strings.NewReader(`{"model":"gpt","messages":[]}`))
if err != nil {
t.Fatalf("构建请求失败: %v", err)
}
req.Header.Set("Authorization", "Bearer sk-abcdefghijklmnop")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatalf("请求失败: %v", err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
snap := a.snapshot()
// 段2:上游请求捕获(headers 脱敏 + URL 凭证脱敏 + body 完整)
if snap.Method != http.MethodPost {
t.Errorf("method = %q", snap.Method)
}
if !strings.Contains(snap.URL, "topsec****2345") {
t.Errorf("URL 凭证未脱敏: %q", snap.URL)
}
if got := snap.ReqHeaders["Authorization"]; got != "Bearer****mnop" {
t.Errorf("Authorization 未脱敏: %q", got)
}
if string(snap.ReqBody) != `{"model":"gpt","messages":[]}` {
t.Errorf("上游请求体捕获不完整: %q", snap.ReqBody)
}
// 段3:上游响应捕获(分块写入也应完整)
if snap.Status != 200 {
t.Errorf("status = %d", snap.Status)
}
if !strings.Contains(string(body), `{"chunk":1}`) {
t.Errorf("客户端读取的响应不完整: %q", body)
}
if string(snap.RespBody) != `{"chunk":1}{"chunk":2}` {
t.Errorf("上游响应体镜像不完整: %q", snap.RespBody)
}
if snap.LatencyMs < 0 {
t.Errorf("latency = %d", snap.LatencyMs)
}
}
func TestDebugRoundTripperError(t *testing.T) {
// 连接失败:err 路径记录 upstreamError,不 panic
a := &DebugAttemptCapture{}
ctx := WithDebugAttempt(context.Background(), a)
client := &http.Client{Transport: WrapDebugTransport(http.DefaultTransport.(*http.Transport).Clone())}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/nope", nil)
_, err := client.Do(req)
if err == nil {
t.Fatal("期望连接失败")
}
snap := a.snapshot()
if snap.Err == "" {
t.Error("传输层错误未被记录")
}
}
func base64Decode(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}