- 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 下全绿
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
package turnstile
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/nyoungo/dsfree2api/internal/config"
|
||
"github.com/nyoungo/dsfree2api/internal/httpx"
|
||
)
|
||
|
||
// 回归:retries=0 时求解循环必须至少跑一次,不能直接返回 ("", nil) ——
|
||
// 那会让 refreshLocked 以为求解成功,把未经验证的 cookie 缓存进池子。
|
||
func TestSolveTokenAPIRetriesZeroStillAttemptsOnce(t *testing.T) {
|
||
var calls atomic.Int32
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
calls.Add(1)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"token":"tok-1","elapsed":0.1}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
solver := New(config.Turnstile{
|
||
Enabled: true, Provider: config.ProviderAPI, APIStyle: "ezsolver",
|
||
APIURL: srv.URL, TimeoutSeconds: 5, Retries: 0,
|
||
}, httpx.DefaultUserAgent)
|
||
|
||
token, err := solver.solveTokenAPI(context.Background(), "https://deepseek.de/", "0xabc", solver.coreFor(""))
|
||
if err != nil {
|
||
t.Fatalf("solve: %v", err)
|
||
}
|
||
if calls.Load() != 1 {
|
||
t.Fatalf("solve API 被调用 %d 次,want 1", calls.Load())
|
||
}
|
||
if token == "" {
|
||
t.Fatal("retries=0 返回空 token 且无错误 —— 上游根本没被调用")
|
||
}
|
||
}
|
||
|
||
// 回归:retries=0 时校验循环也必须至少跑一次,否则会跳过 verify 直接成功。
|
||
func TestVerifyTokenRetriesZeroStillVerifiesOnce(t *testing.T) {
|
||
var calls atomic.Int32
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
calls.Add(1)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
solver := New(config.Turnstile{
|
||
Enabled: true, Provider: config.ProviderAPI,
|
||
APIURL: srv.URL, TimeoutSeconds: 5, Retries: 0,
|
||
}, httpx.DefaultUserAgent)
|
||
|
||
sess, err := httpx.NewSession(httpx.Options{Timeout: 5 * time.Second})
|
||
if err != nil {
|
||
t.Fatalf("session: %v", err)
|
||
}
|
||
defer sess.Close()
|
||
|
||
site := &config.Site{
|
||
Code: "t", BaseURL: srv.URL, AJAXURL: srv.URL,
|
||
VerifyAction: "verify", Language: "en",
|
||
}
|
||
if err := solver.verifyToken(context.Background(), sess, site, "tok"); err != nil {
|
||
t.Fatalf("verify: %v", err)
|
||
}
|
||
if calls.Load() != 1 {
|
||
t.Fatalf("verify 被调用 %d 次,want 1 —— 空 token 会被判定为校验成功", calls.Load())
|
||
}
|
||
}
|
||
|
||
// 回归:coreFor 拿到非法代理 URL 时不能吞掉 StdClient 的错误并缓存 nil client,
|
||
// 否则 solveTokenAPI 里 client.Do 会 nil panic(warmer 后台协程没有 recover,
|
||
// 一次就能拖垮整个进程)。
|
||
func TestCoreForInvalidProxyDoesNotPanic(t *testing.T) {
|
||
solver := New(config.Turnstile{
|
||
Enabled: true, Provider: config.ProviderAPI, APIStyle: "ezsolver",
|
||
APIURL: "https://solve.example.invalid/", TimeoutSeconds: 5, Retries: 1,
|
||
}, httpx.DefaultUserAgent)
|
||
|
||
c := solver.coreFor("://bad proxy")
|
||
if c == nil {
|
||
t.Fatal("coreFor returned nil")
|
||
}
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
t.Fatalf("coreFor 留下 nil client 导致 panic: %v", r)
|
||
}
|
||
}()
|
||
|
||
token, err := solver.solveTokenAPI(context.Background(), "https://deepseek.de/", "0xabc", c)
|
||
if err == nil {
|
||
t.Fatalf("非法代理应当报错,却返回 token=%q", token)
|
||
}
|
||
}
|