You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

253 lines
8.7 KiB

package console
// 探针的端到端测试:用 httptest 起本地假服务商,验证探针发出的请求形状
// (路径/鉴权头/退化路径)与对各种响应的判定。全程不出网,不碰任何真实服务商。
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"yunyan/comm"
)
func ctx(t *testing.T) context.Context {
t.Helper()
c, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
return c
}
// TestProbeOpenAICompatOK 正常路径:GET {base}/models 带 Bearer,200 即凭据有效。
func TestProbeOpenAICompatOK(t *testing.T) {
var gotPath, gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"data":[]}`))
}))
defer srv.Close()
res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "sk-1", "base_url": srv.URL}, "", "测试")
if !res.Ok || res.Kind != comm.ProbeCredential {
t.Fatalf("期望凭据校验通过,实得 %+v", res)
}
if gotPath != "/models" {
t.Errorf("探测路径 = %q, want /models", gotPath)
}
if gotAuth != "Bearer sk-1" {
t.Errorf("鉴权头 = %q, want 'Bearer sk-1'", gotAuth)
}
}
// TestProbeOpenAICompat401 密钥被拒必须判为不可用,这是巡检要抓的主要故障。
func TestProbeOpenAICompat401(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":"invalid api key"}`))
}))
defer srv.Close()
res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "bad", "base_url": srv.URL}, "", "测试")
if res.Ok {
t.Fatalf("401 必须判为失败,实得 %+v", res)
}
if !strings.Contains(res.Msg, "invalid api key") {
t.Errorf("失败原因应带上服务商返回内容,实得 %q", res.Msg)
}
}
// TestProbeOpenAICompatFallback 中转网关常不实现 /models,此时应退化为最小 chat 请求验鉴权。
func TestProbeOpenAICompatFallback(t *testing.T) {
var hitChat bool
var chatBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/models" {
w.WriteHeader(404)
return
}
if r.URL.Path == "/chat/completions" {
hitChat = true
buf := make([]byte, r.ContentLength)
_, _ = r.Body.Read(buf)
chatBody = string(buf)
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"choices":[]}`))
return
}
w.WriteHeader(500)
}))
defer srv.Close()
res := probeOpenAICompat(ctx(t),
map[string]string{"api_key": "sk-1", "base_url": srv.URL, "model": "test-model"}, "", "测试")
if !hitChat {
t.Fatal("/models 返回 404 后应退化调用 /chat/completions")
}
if !res.Ok || res.Kind != comm.ProbeCredential {
t.Fatalf("退化路径 200 应判为凭据有效,实得 %+v", res)
}
// 退化请求必须是最小的,不能真跑一次推理。
if !strings.Contains(chatBody, `"max_tokens":1`) {
t.Errorf("退化请求应限制 max_tokens=1,实得 %s", chatBody)
}
}
// TestProbeOpenAICompatFallbackNoModel 没配 model 就无法退化,此时必须如实说「未探测」,
// 不能默默判成可用。
func TestProbeOpenAICompatFallbackNoModel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(404)
}))
defer srv.Close()
res := probeOpenAICompat(ctx(t), map[string]string{"api_key": "sk-1", "base_url": srv.URL}, "", "测试")
if res.Kind != comm.ProbeNone {
t.Fatalf("无法验证时必须标记为未探测,实得 %+v", res)
}
}
func TestProbeOpenAICompatNoKey(t *testing.T) {
res := probeOpenAICompat(ctx(t), map[string]string{}, "https://example.invalid", "测试")
if res.Ok {
t.Fatalf("缺 api_key 应直接判失败,实得 %+v", res)
}
}
// TestProbeAnthropic Anthropic 用 x-api-key + 版本头,头错会被判 401,必须发对。
func TestProbeAnthropicHeaders(t *testing.T) {
var key, ver string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key, ver = r.Header.Get("x-api-key"), r.Header.Get("anthropic-version")
w.WriteHeader(200)
}))
defer srv.Close()
// 直接验证头部构造:借 doProbe 复现 probeAnthropic 的请求形状。
_, _, err := doProbe(ctx(t), http.MethodGet, srv.URL,
map[string]string{"x-api-key": "k", "anthropic-version": "2023-06-01"}, nil)
if err != nil {
t.Fatal(err)
}
if key != "k" || ver != "2023-06-01" {
t.Errorf("Anthropic 鉴权头不正确: key=%q version=%q", key, ver)
}
}
// TestProbeMCPHandshake MCP 走 JSON-RPC initialize 握手。
func TestProbeMCPHandshake(t *testing.T) {
var body string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, r.ContentLength)
_, _ = r.Body.Read(buf)
body = string(buf)
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05"}}`))
}))
defer srv.Close()
res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "0"})
if !res.Ok || res.Kind != comm.ProbeCredential {
t.Fatalf("握手成功应判可用,实得 %+v", res)
}
if !strings.Contains(body, `"method":"initialize"`) {
t.Errorf("应发送 initialize 握手,实得 %s", body)
}
}
// TestProbeMCPRpcError 服务在线但拒绝初始化,属于要人工看的异常,不能算通过。
func TestProbeMCPRpcError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"bad request"}}`))
}))
defer srv.Close()
res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "0"})
if res.Ok {
t.Fatalf("JSON-RPC error 应判为失败,实得 %+v", res)
}
}
// TestProbeMCPSSE SSE 是长连接流,只能做连通性探测,证据等级必须降级。
func TestProbeMCPSSE(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
res := probeMCP(ctx(t), map[string]string{"url": srv.URL, "type": "1"})
if !res.Ok || res.Kind != comm.ProbeReachable {
t.Fatalf("SSE 应判为仅连通性,实得 %+v", res)
}
}
func TestProbeMCPNoURL(t *testing.T) {
if res := probeMCP(ctx(t), map[string]string{}); res.Ok {
t.Fatalf("缺 url 应判失败,实得 %+v", res)
}
}
// TestHTTPReachable 连不上要判失败;连上任意响应都算地址在线但不验证凭据。
func TestHTTPReachable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(503)
}))
defer srv.Close()
res := httpReachable(ctx(t), srv.URL)
if !res.Ok || res.Kind != comm.ProbeReachable {
t.Fatalf("503 也说明地址在线,实得 %+v", res)
}
srv.Close()
if res = httpReachable(ctx(t), srv.URL); res.Ok {
t.Fatalf("服务已关闭应判不可达,实得 %+v", res)
}
}
// TestProbeServiceUnknownProvider 未覆盖的服务商:有地址就探连通性,没地址就如实说没探。
func TestProbeServiceUnknownProvider(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
res := probeService(ctx(t), "某个没接过的服务商", map[int32]bool{}, map[string]string{"base_url": srv.URL})
if res.Kind != comm.ProbeReachable {
t.Errorf("未知服务商有地址时应做连通性探测,实得 %+v", res)
}
res = probeService(ctx(t), "某个没接过的服务商", map[int32]bool{}, map[string]string{"secret": "x"})
if res.Kind != comm.ProbeNone {
t.Errorf("未知服务商且无地址时应标记未探测,实得 %+v", res)
}
}
// TestProbeVolcengine 火山没有免费只读校验接口:字段齐全应如实标记未探测(而不是伪装成通过),
// 字段缺失则直接判失败。
func TestProbeVolcengine(t *testing.T) {
res := probeVolcengine(map[string]string{"appid": "a", "token": "t"})
if res.Kind != comm.ProbeNone || !res.Ok {
t.Errorf("字段齐全应标记未探测,实得 %+v", res)
}
res = probeVolcengine(map[string]string{"appid": "a"})
if res.Ok {
t.Errorf("缺 token 应判失败,实得 %+v", res)
}
}
func TestFirstURLFieldAndPick(t *testing.T) {
f := map[string]string{"endpoint": "not-a-url", "base_url": "https://x.test"}
if got := firstURLField(f); got != "https://x.test" {
t.Errorf("firstURLField = %q, 应跳过非 http 值取到 base_url", got)
}
if got := pick(f, "missing", "endpoint"); got != "not-a-url" {
t.Errorf("pick 应按顺序取第一个非空值,实得 %q", got)
}
if got := pick(f, "missing"); got != "" {
t.Errorf("pick 全缺失应返回空串,实得 %q", got)
}
}