- 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 下全绿
124 lines
3.6 KiB
Go
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
|
|
}
|