- 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 下全绿
286 lines
8.7 KiB
Go
286 lines
8.7 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/nyoungo/dsfree2api/internal/config"
|
||
"github.com/nyoungo/dsfree2api/internal/logbuf"
|
||
"github.com/nyoungo/dsfree2api/internal/metrics"
|
||
"github.com/nyoungo/dsfree2api/internal/turnstile"
|
||
)
|
||
|
||
const examplePath = "../../config.example.toml"
|
||
const testKey = "sk-dsfr-local-change-me"
|
||
|
||
func newTestServer(t *testing.T, mutate func(*config.Config)) *httptest.Server {
|
||
t.Helper()
|
||
cfg, err := config.Load(examplePath)
|
||
if err != nil {
|
||
t.Fatalf("load config: %v", err)
|
||
}
|
||
cfg.Security.APIKeys = []string{testKey}
|
||
if mutate != nil {
|
||
mutate(cfg)
|
||
}
|
||
met := metrics.New(t.TempDir())
|
||
met.Start()
|
||
t.Cleanup(met.Close)
|
||
logs := logbuf.New(50)
|
||
ts := turnstile.New(cfg.Turnstile, "")
|
||
srv := New(cfg, nil, met, logs, ts, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||
tsrv := httptest.NewServer(srv.Handler())
|
||
t.Cleanup(tsrv.Close)
|
||
return tsrv
|
||
}
|
||
|
||
func get(t *testing.T, url string, headers map[string]string) (*http.Response, string) {
|
||
t.Helper()
|
||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||
if err != nil {
|
||
t.Fatalf("new request: %v", err)
|
||
}
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("do: %v", err)
|
||
}
|
||
body, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
return resp, string(body)
|
||
}
|
||
|
||
func TestModelsRequiresAuth(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
resp, _ := get(t, srv.URL+"/v1/models", nil)
|
||
if resp.StatusCode != http.StatusUnauthorized {
|
||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
func TestModelsRejectsWrongKey(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
resp, _ := get(t, srv.URL+"/v1/models", map[string]string{"Authorization": "Bearer sk-wrong"})
|
||
if resp.StatusCode != http.StatusUnauthorized {
|
||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
func TestModelsListsWithAuth(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
resp, body := get(t, srv.URL+"/v1/models", map[string]string{"Authorization": "Bearer " + testKey})
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d body=%s", resp.StatusCode, body)
|
||
}
|
||
var out struct {
|
||
Data []struct {
|
||
ID string `json:"id"`
|
||
} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if len(out.Data) != 6 {
|
||
t.Fatalf("len(data) = %d, want 6", len(out.Data))
|
||
}
|
||
ids := map[string]bool{}
|
||
for _, m := range out.Data {
|
||
ids[m.ID] = true
|
||
}
|
||
for _, want := range []string{"deepseek-v4-flash-es", "deepseek-v4-pro-de"} {
|
||
if !ids[want] {
|
||
t.Errorf("missing model %q", want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestModelsWithXApiKeyHeader(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
resp, _ := get(t, srv.URL+"/v1/models", map[string]string{"X-Api-Key": testKey})
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
func TestNoKeysMeansNoAuth(t *testing.T) {
|
||
srv := newTestServer(t, func(c *config.Config) { c.Security.APIKeys = nil })
|
||
resp, _ := get(t, srv.URL+"/v1/models", nil)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
func TestRateLimit(t *testing.T) {
|
||
srv := newTestServer(t, func(c *config.Config) { c.Limits.RatePerMinute = 1 })
|
||
h := map[string]string{"Authorization": "Bearer " + testKey}
|
||
if resp, _ := get(t, srv.URL+"/v1/models", h); resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("first status = %d, want 200", resp.StatusCode)
|
||
}
|
||
if resp, _ := get(t, srv.URL+"/v1/models", h); resp.StatusCode != http.StatusTooManyRequests {
|
||
t.Fatalf("second status = %d, want 429", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
func TestHealthAndRoot(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
resp, body := get(t, srv.URL+"/health", nil)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("health status = %d", resp.StatusCode)
|
||
}
|
||
var health map[string]any
|
||
if err := json.Unmarshal([]byte(body), &health); err != nil {
|
||
t.Fatalf("decode: %v", err)
|
||
}
|
||
if health["status"] != "ok" {
|
||
t.Errorf("health = %v", health)
|
||
}
|
||
if _, ok := health["models"]; !ok {
|
||
t.Errorf("health missing models: %v", health)
|
||
}
|
||
}
|
||
|
||
func TestUnknownRouteIs404(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
resp, _ := get(t, srv.URL+"/v1/nope", map[string]string{"Authorization": "Bearer " + testKey})
|
||
if resp.StatusCode != http.StatusNotFound {
|
||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
func post(t *testing.T, url string, headers map[string]string, body string) (*http.Response, string) {
|
||
t.Helper()
|
||
req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
|
||
if err != nil {
|
||
t.Fatalf("new request: %v", err)
|
||
}
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
resp, err := http.DefaultClient.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("do: %v", err)
|
||
}
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
return resp, string(raw)
|
||
}
|
||
|
||
func anthropicErrType(body string) string {
|
||
var out struct {
|
||
Type string `json:"type"`
|
||
Error struct {
|
||
Type string `json:"type"`
|
||
} `json:"error"`
|
||
}
|
||
_ = json.Unmarshal([]byte(body), &out)
|
||
if out.Type == "error" {
|
||
return out.Error.Type
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// openaiErrType 解析 OpenAI 风格错误体 {"error":{"type":...}}。
|
||
func openaiErrType(body string) string {
|
||
var out struct {
|
||
Error struct {
|
||
Type string `json:"type"`
|
||
} `json:"error"`
|
||
}
|
||
_ = json.Unmarshal([]byte(body), &out)
|
||
return out.Error.Type
|
||
}
|
||
|
||
// openaiErrMsg 取出 error.message(JSON 会把 ">" 转义成 >,
|
||
// 所以断言文案必须走反序列化,不能拿原文子串匹配)。
|
||
func openaiErrMsg(body string) string {
|
||
var out struct {
|
||
Error struct {
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
_ = json.Unmarshal([]byte(body), &out)
|
||
return out.Error.Message
|
||
}
|
||
|
||
func TestMessagesRequiresAuthInAnthropicFormat(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
resp, body := post(t, srv.URL+"/v1/messages", nil, `{}`)
|
||
if resp.StatusCode != http.StatusUnauthorized {
|
||
t.Fatalf("status = %d body=%s, want 401", resp.StatusCode, body)
|
||
}
|
||
if got := anthropicErrType(body); got != "authentication_error" {
|
||
t.Errorf("error type = %q, want authentication_error; body=%s", got, body)
|
||
}
|
||
}
|
||
|
||
func TestMessagesValidation(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
h := map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"}
|
||
|
||
resp, body := post(t, srv.URL+"/v1/messages", h, `{}`)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("empty body status = %d body=%s, want 400", resp.StatusCode, body)
|
||
}
|
||
if got := anthropicErrType(body); got != "invalid_request_error" {
|
||
t.Errorf("error type = %q, want invalid_request_error", got)
|
||
}
|
||
|
||
resp, body = post(t, srv.URL+"/v1/messages", h,
|
||
`{"model":"no-such-model","messages":[{"role":"user","content":"hi"}]}`)
|
||
if resp.StatusCode != http.StatusNotFound {
|
||
t.Fatalf("unknown model status = %d body=%s, want 404", resp.StatusCode, body)
|
||
}
|
||
if got := anthropicErrType(body); got != "not_found_error" {
|
||
t.Errorf("error type = %q, want not_found_error", got)
|
||
}
|
||
}
|
||
|
||
func TestResponsesValidation(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
h := map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"}
|
||
|
||
resp, body := post(t, srv.URL+"/v1/responses", h, `{"model":"deepseek-v4-flash-de"}`)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("missing input status = %d body=%s, want 400", resp.StatusCode, body)
|
||
}
|
||
|
||
resp, body = post(t, srv.URL+"/v1/responses", h,
|
||
`{"model":"no-such-model","input":"hi"}`)
|
||
if resp.StatusCode != http.StatusNotFound {
|
||
t.Fatalf("unknown model status = %d body=%s, want 404", resp.StatusCode, body)
|
||
}
|
||
}
|
||
|
||
// 回归:n>1 必须 400 —— 网关只产 1 条 choice,静默忽略会让客户端以为
|
||
// 拿到了 n 条候选。校验排在模型查找之前(模型不存在也先报参数错)。
|
||
func TestChatRejectsNGreaterThanOne(t *testing.T) {
|
||
srv := newTestServer(t, nil)
|
||
h := map[string]string{"Authorization": "Bearer " + testKey, "Content-Type": "application/json"}
|
||
|
||
resp, body := post(t, srv.URL+"/v1/chat/completions", h,
|
||
`{"model":"no-such-model","messages":[{"role":"user","content":"hi"}],"n":2}`)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("n=2 status = %d body=%s, want 400", resp.StatusCode, body)
|
||
}
|
||
if !strings.Contains(openaiErrMsg(body), "n > 1") {
|
||
t.Errorf("error message should mention n > 1, body=%s", body)
|
||
}
|
||
if got := openaiErrType(body); got != "invalid_request_error" {
|
||
t.Errorf("error type = %q, want invalid_request_error; body=%s", got, body)
|
||
}
|
||
|
||
// n=1(含省略)照常放行到模型查找,返回 404
|
||
resp, body = post(t, srv.URL+"/v1/chat/completions", h,
|
||
`{"model":"no-such-model","messages":[{"role":"user","content":"hi"}],"n":1}`)
|
||
if resp.StatusCode != http.StatusNotFound {
|
||
t.Fatalf("n=1 status = %d body=%s, want 404", resp.StatusCode, body)
|
||
}
|
||
}
|