Files
nyoung ed51efd8bc fix: 网关与控制台代码审查修复批次(含回归测试)
- Chat 接口拒绝 n>1(此前静默丢弃多余 choice),返回 400 结构化错误
- Responses 接口透传 text.format(json_schema / json_object),三种接口协议对齐
- TLS 指纹统一升级到 Chrome_150(internal/httpx.DefaultProfile),与 UA 匹配
- metrics 落盘失败不再静默:Save() 返回错误并打日志,临时文件失败会清理
- GET /api/config 脱敏 admin.password 与 turnstile.api_key(控制台从不展示,
  不应随 5s 轮询下发);PUT 改走 ReplaceJSON,先校验后替换、失败回滚
- set_limits / set_turnstile 改指针部分更新:缺字段保持现状,不会把并发、
  限流或求解开关按零值清掉
- 读请求体统一 64MB 上限;配置写盘原子替换 + 0600 权限
- main.go 改为 run() 返回退出码:日志落盘、指标落盘、xray 子进程回收全部
  挂 defer,任何退出路径(含启动失败)都会执行,杜绝孤儿 xray
- proxypool / turnstile / upstream / openai / config / httpx / logbuf 等
  逐项补红绿回归测试,新增 14 个测试文件,race 下全绿
2026-10-08 15:13:23 +08:00

124 lines
3.6 KiB
Go

package httpx
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"unicode/utf8"
)
// 回归:按字节截断会切碎多字节字符,产出非法 UTF-8 的错误文本
// (随后进 JSON/日志时会变成乱码)。
func TestTruncateKeepsUTF8Valid(t *testing.T) {
src := strings.Repeat("汉", 100) // 300 bytes
got := truncate(src, 10)
if !utf8.ValidString(got) {
t.Fatalf("truncate 切出了非法 UTF-8: %q (bytes=%v)", got, []byte(got[:min(len(got), 14)]))
}
if !strings.HasSuffix(got, "...") {
t.Fatalf("got %q, want an ellipsis suffix", got)
}
}
// 回归:不超长的字符串原样返回。
func TestTruncateShortInputUnchanged(t *testing.T) {
if got := truncate("hello", 10); got != "hello" {
t.Fatalf("got %q", got)
}
}
// 回归:非流式响应体必须有读取上限。裸 io.ReadAll 会让上游(或中间人)
// 用一个超大响应把网关内存打爆(OOM)。
func TestDoRejectsOversizedBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
chunk := make([]byte, 32<<10)
for i := 0; i < 64; i++ { // 2MB,远超下面设的 64KB 上限
if _, err := w.Write(chunk); err != nil {
return
}
}
}))
defer srv.Close()
s, err := NewSession(Options{Timeout: 15 * time.Second, MaxBodyBytes: 64 << 10})
if err != nil {
t.Fatalf("session: %v", err)
}
defer s.Close()
resp, err := s.Do(context.Background(), Request{Method: "GET", URL: srv.URL})
if !errors.Is(err, ErrBodyTooLarge) {
t.Fatalf("err = %v (resp=%v), want ErrBodyTooLarge", err, resp)
}
}
// 回归:上限以内的响应体必须原样读全,不能误伤正常请求。
func TestDoReadsBodyWithinLimit(t *testing.T) {
want := strings.Repeat("x", 4096)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(want))
}))
defer srv.Close()
s, err := NewSession(Options{Timeout: 15 * time.Second, MaxBodyBytes: 64 << 10})
if err != nil {
t.Fatalf("session: %v", err)
}
defer s.Close()
resp, err := s.Do(context.Background(), Request{Method: "GET", URL: srv.URL})
if err != nil {
t.Fatalf("do: %v", err)
}
if string(resp.Body) != want {
t.Fatalf("body len = %d, want %d", len(resp.Body), len(want))
}
}
// 回归:TLS 指纹档案的 Chrome 主版本必须与默认 UA 的主版本贴近。
// 曾经 UA=Chrome/149 而指纹=Chrome_120,差 29 个大版本 —— JA3 指纹与
// UA 自相矛盾,是可识别的异常客户端特征。
func TestTLSFingerprintAlignedWithDefaultUA(t *testing.T) {
uaMajor, ok := uaChromeMajor(DefaultUserAgent)
if !ok {
t.Fatalf("DefaultUserAgent 里解析不出 Chrome 主版本: %q", DefaultUserAgent)
}
id := DefaultProfile().GetClientHelloId()
if !strings.EqualFold(id.Client, "Chrome") {
t.Fatalf("指纹档案客户端 = %q, want Chrome", id.Client)
}
profMajor, err := strconv.Atoi(id.Version)
if err != nil {
t.Fatalf("指纹档案版本 = %q, want 纯数字主版本", id.Version)
}
if d := uaMajor - profMajor; d < -1 || d > 1 {
t.Fatalf("UA 主版本 %d 与指纹档案主版本 %d 相差 %d(>1),指纹与 UA 脱节",
uaMajor, profMajor, d)
}
}
// uaChromeMajor 从 UA 串里取出 "Chrome/<major>" 的主版本号。
func uaChromeMajor(ua string) (int, bool) {
const marker = "Chrome/"
i := strings.Index(ua, marker)
if i < 0 {
return 0, false
}
rest := ua[i+len(marker):]
j := strings.Index(rest, ".")
if j <= 0 {
return 0, false
}
n, err := strconv.Atoi(rest[:j])
if err != nil {
return 0, false
}
return n, true
}