主人定调「先把原生的做进插件,MCP 路线等后端 MCP 完善后再接」。 - 新包 plugins/tools/io/search/:一 provider 一文件 + 共享 http/提取器 + 归一化 Result;域名过滤/去重/排序**归一化后统一做一次** - 配置 15 键:WEB_SEARCH_PROVIDER(默认 searxng)/_FALLBACK/_MAX_RESULTS + 五家 ×(API_KEY/BASE_URL);WEB_SEARCH_TIMEOUT 语义改为「**每次上游尝试**」(降级链才有意义) - 硬语义:未注册/未配置的主 provider ⇒ **启动期报错**(不许静默回落);off ⇒ 不注册工具;缺 key 的家不进可用清单;降级链**如实标实际应答的 provider**(变异:拿主力冒充 ⇒ 测试红「provider = zhipu, want bocha」) - 密钥绝不进日志/响应(有断言);错误分类(鉴权/限流/额度/网络)+ **空结果 ≠ 成功**(变异暴露过一个死分支⇒下移到 searchWith 修掉) - searxng 真机 e2e 通过(provider=searxng 5 条);**其余五家无 key ⇒ 如实标未实测**;契约无改动(无新端点、工具 schema 不变) - 注册表留好 B 档接入位(tavily/exa/firecrawl/serper/serpapi),并有「未登记⇒启动报错」的防回归断言
367 lines
14 KiB
Go
367 lines
14 KiB
Go
package io
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||
)
|
||
|
||
// 本文件是切片 WS1-B 的 io 侧契约:**后端由配置选定**(provider 注册表 + 降级链),
|
||
// 「未注册 / 未配置」的选定必须**启动期报错**(绝不静默回落),缺 key 的家不进可用清单,
|
||
// 降级链只在「不可用」时前进且**如实标注实际应答的 provider**,密钥不泄漏,
|
||
// 空结果 ≠ 成功。
|
||
|
||
// leakKey 是夹具用的假密钥:它只允许出现在请求头里。
|
||
const leakKey = "sk-LEAK-CANARY-0123456789"
|
||
|
||
// jsonServer 造一个固定应答的测试服务器(记录命中次数)。
|
||
func jsonServer(t *testing.T, status int, body string) (*httptest.Server, *int) {
|
||
t.Helper()
|
||
hits := 0
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
hits++
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(status)
|
||
_, _ = w.Write([]byte(body))
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
return srv, &hits
|
||
}
|
||
|
||
// searxngOK 是 searxng 形状的一条结果应答。
|
||
const searxngOK = `{"results":[{"title":"兜底结果","url":"https://fallback.example/1","content":"兜底正文"}]}`
|
||
|
||
// bochaOK 是博查形状的一条结果应答。
|
||
const bochaOK = `{"webPages":{"value":[{"name":"博查结果","url":"https://bocha.example/1","summary":"博查正文"}]}}`
|
||
|
||
// searchNew 走 New 这条**组合根同款**的路构造工具集(可拿到启动期错误)。
|
||
func searchNew(t *testing.T, opts ...Option) ([]sdk.Tool, error) {
|
||
t.Helper()
|
||
return New(&config.Config{}, append([]Option{WithLogger(silentLogger())}, opts...)...)
|
||
}
|
||
|
||
// searchTool 构造一把 web-search 工具(构造失败即 Fatal)。
|
||
func searchTool(t *testing.T, specs []SearchProviderSpec, primary string, fallback []string, opts ...Option) *webSearchTool {
|
||
t.Helper()
|
||
all := append([]Option{
|
||
WithSearchProviders(specs...),
|
||
WithSearchSelection(primary, fallback),
|
||
}, opts...)
|
||
set, err := searchNew(t, all...)
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
return findTool(t, set, "web-search").(*webSearchTool)
|
||
}
|
||
|
||
// cloudSpec 造一条云 provider 的接线(Configured 的判定口径与组合根一致)。
|
||
func cloudSpec(name, key, base string) SearchProviderSpec {
|
||
return SearchProviderSpec{
|
||
Name: name, APIKey: key, BaseURL: base,
|
||
Configured: search.Configured(name, key, base),
|
||
}
|
||
}
|
||
|
||
// TestSearch_UnknownProviderFailsStartup 是验收 ③ 第一条:`WEB_SEARCH_PROVIDER` 指向
|
||
// **未登记**的名字(如本期未做的 tavily)⇒ 启动期显式报错,绝不静默回落到兜底。
|
||
func TestSearch_UnknownProviderFailsStartup(t *testing.T) {
|
||
t.Parallel()
|
||
searxng := jsonServerMust(t, searxngOK)
|
||
specs := []SearchProviderSpec{
|
||
{Name: search.NameSearXNG, BaseURL: searxng.URL, Configured: true},
|
||
}
|
||
for _, name := range []string{"tavily", "exa", "firecrawl", "serper", "serpapi", "typo-name"} {
|
||
_, err := searchNew(t,
|
||
WithSearchProviders(specs...),
|
||
WithSearchSelection(name, []string{search.NameSearXNG}),
|
||
)
|
||
if err == nil {
|
||
t.Fatalf("选定未登记的 provider %q 必须启动报错(不许静默回落到 searxng)", name)
|
||
}
|
||
if !errors.Is(err, search.ErrUnknownProvider) {
|
||
t.Fatalf("provider=%q 的错误 = %v, want errors.Is(search.ErrUnknownProvider)", name, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestSearch_UnconfiguredPrimaryFailsStartup 是验收 ③ 第二条:名字认识但**缺 key**
|
||
// ⇒ 启动报错;即使降级链里有一家配好的兜底,也**不许**悄悄换过去。
|
||
func TestSearch_UnconfiguredPrimaryFailsStartup(t *testing.T) {
|
||
t.Parallel()
|
||
searxng := jsonServerMust(t, searxngOK)
|
||
specs := []SearchProviderSpec{
|
||
{Name: search.NameSearXNG, BaseURL: searxng.URL, Configured: true},
|
||
cloudSpec(search.NameZhipu, "", "https://open.bigmodel.cn"), // 缺 key
|
||
}
|
||
_, err := searchNew(t,
|
||
WithSearchProviders(specs...),
|
||
WithSearchSelection(search.NameZhipu, []string{search.NameSearXNG}),
|
||
)
|
||
if err == nil {
|
||
t.Fatal("选定未配置(缺 key)的 provider 必须启动报错")
|
||
}
|
||
if !errors.Is(err, search.ErrNotConfigured) {
|
||
t.Fatalf("err = %v, want errors.Is(search.ErrNotConfigured)", err)
|
||
}
|
||
if !strings.Contains(err.Error(), search.NameZhipu) {
|
||
t.Fatalf("错误必须点出是哪一家: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestSearch_MissingKeyFamilyNotInAvailableList 是验收 ③ 第三条:没配 key 的家
|
||
// **不出现在可用清单里**(fail-closed:不挂着一把必报错的锤子)。
|
||
func TestSearch_MissingKeyFamilyNotInAvailableList(t *testing.T) {
|
||
t.Parallel()
|
||
searxng := jsonServerMust(t, searxngOK)
|
||
bocha := jsonServerMust(t, bochaOK)
|
||
specs := []SearchProviderSpec{
|
||
{Name: search.NameSearXNG, BaseURL: searxng.URL, Configured: true},
|
||
cloudSpec(search.NameZhipu, "", "https://open.bigmodel.cn"), // 缺 key ⇒ 不可用
|
||
cloudSpec(search.NameBocha, leakKey, bocha.URL), // 有 key ⇒ 可用
|
||
}
|
||
ws := searchTool(t, specs, search.NameBocha, nil)
|
||
names := ws.registry.Names()
|
||
if !containsName(names, search.NameBocha) || !containsName(names, search.NameSearXNG) {
|
||
t.Fatalf("可用清单 = %v, 应含 bocha 与 searxng", names)
|
||
}
|
||
if containsName(names, search.NameZhipu) {
|
||
t.Fatalf("缺 key 的 zhipu 不得出现在可用清单里: %v", names)
|
||
}
|
||
if _, ok := ws.registry.Lookup(search.NameZhipu); ok {
|
||
t.Fatal("缺 key 的 zhipu 不得出现在注册表里")
|
||
}
|
||
}
|
||
|
||
// TestSearch_FallbackChainAdvancesAndLabelsProvider 是验收 ③ 第四条(含变异 ② 的判据):
|
||
// 主家被限流 ⇒ 前进一格到备家;结果里**如实标注备家**,绝不拿兜底冒充主力。
|
||
func TestSearch_FallbackChainAdvancesAndLabelsProvider(t *testing.T) {
|
||
t.Parallel()
|
||
zhipuSrv, zhipuHits := jsonServer(t, http.StatusTooManyRequests, `{"error":"rate limited"}`)
|
||
bochaSrv, bochaHits := jsonServer(t, http.StatusOK, bochaOK)
|
||
|
||
ws := searchTool(t,
|
||
[]SearchProviderSpec{
|
||
cloudSpec(search.NameZhipu, leakKey, zhipuSrv.URL),
|
||
cloudSpec(search.NameBocha, leakKey, bochaSrv.URL),
|
||
},
|
||
search.NameZhipu, []string{search.NameBocha},
|
||
WithHTTPClient(zhipuSrv.Client()),
|
||
)
|
||
got, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
|
||
if err != nil {
|
||
t.Fatalf("主家限流后备家应接住,却报错: %v", err)
|
||
}
|
||
m := got.(map[string]any)
|
||
if m["provider"] != search.NameBocha {
|
||
t.Fatalf("provider = %v, want %q —— 必须如实标注**实际应答**的家,不许冒充主力",
|
||
m["provider"], search.NameBocha)
|
||
}
|
||
if *zhipuHits != 1 || *bochaHits != 1 {
|
||
t.Fatalf("命中次数 zhipu=%d bocha=%d, want 1/1(先试主家、失败后前进一格)", *zhipuHits, *bochaHits)
|
||
}
|
||
results := m["results"].([]searchResult)
|
||
if len(results) != 1 || results[0].URL != "https://bocha.example/1" {
|
||
t.Fatalf("结果应来自备家: %+v", results)
|
||
}
|
||
}
|
||
|
||
// TestSearch_FallbackSkipsUnknownAndUnconfigured 钉住降级链上的「跳过」不是「报错」:
|
||
// 未登记(tavily)与未配置(缺 key 的 bocha)只是被跳过,链继续走到能用的 searxng。
|
||
func TestSearch_FallbackSkipsUnknownAndUnconfigured(t *testing.T) {
|
||
t.Parallel()
|
||
zhipuSrv, _ := jsonServer(t, http.StatusUnauthorized, `{"error":"bad key"}`)
|
||
searxngSrv, searxngHits := jsonServer(t, http.StatusOK, searxngOK)
|
||
|
||
ws := searchTool(t,
|
||
[]SearchProviderSpec{
|
||
{Name: search.NameSearXNG, BaseURL: searxngSrv.URL, Configured: true},
|
||
cloudSpec(search.NameZhipu, leakKey, zhipuSrv.URL),
|
||
cloudSpec(search.NameBocha, "", "https://api.bocha.cn"), // 缺 key
|
||
},
|
||
search.NameZhipu, []string{"tavily", search.NameBocha, search.NameSearXNG},
|
||
WithHTTPClient(zhipuSrv.Client()),
|
||
)
|
||
got, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
|
||
if err != nil {
|
||
t.Fatalf("链上跳过后应打到 searxng,却报错: %v", err)
|
||
}
|
||
if m := got.(map[string]any); m["provider"] != search.NameSearXNG {
|
||
t.Fatalf("provider = %v, want %q", m["provider"], search.NameSearXNG)
|
||
}
|
||
if *searxngHits != 1 {
|
||
t.Fatalf("searxng 命中 %d 次, want 1", *searxngHits)
|
||
}
|
||
}
|
||
|
||
// TestSearch_EmptyResultIsNotSuccessAndDoesNotAdvance 是验收 ⑥:
|
||
// 上游 200 + 空结果 ⇒ **不是**成功(报 ErrNoResults),且**不**触发降级链前进
|
||
// (空是空,不是「该家不可用」)。
|
||
func TestSearch_EmptyResultIsNotSuccessAndDoesNotAdvance(t *testing.T) {
|
||
t.Parallel()
|
||
zhipuSrv, _ := jsonServer(t, http.StatusOK, `{"search_result":[]}`)
|
||
bochaSrv, bochaHits := jsonServer(t, http.StatusOK, bochaOK)
|
||
|
||
ws := searchTool(t,
|
||
[]SearchProviderSpec{
|
||
cloudSpec(search.NameZhipu, leakKey, zhipuSrv.URL),
|
||
cloudSpec(search.NameBocha, leakKey, bochaSrv.URL),
|
||
},
|
||
search.NameZhipu, []string{search.NameBocha},
|
||
WithHTTPClient(zhipuSrv.Client()),
|
||
)
|
||
_, err := ws.Execute(context.Background(), map[string]any{"query": "查不到的词"})
|
||
if err == nil {
|
||
t.Fatal("空结果绝不是成功:必须返回错误而不是空列表冒充成功")
|
||
}
|
||
if !errors.Is(err, search.ErrNoResults) {
|
||
t.Fatalf("err = %v, want errors.Is(search.ErrNoResults)", err)
|
||
}
|
||
if !strings.Contains(err.Error(), search.NameZhipu) {
|
||
t.Fatalf("空结果要如实说清是谁答的: %v", err)
|
||
}
|
||
if *bochaHits != 0 {
|
||
t.Fatalf("空结果不该触发降级链前进(bocha 被打了 %d 次)", *bochaHits)
|
||
}
|
||
}
|
||
|
||
// TestSearch_KeyNeverLeaks 是验收 ④:密钥既不进日志,也不进响应/错误。
|
||
func TestSearch_KeyNeverLeaks(t *testing.T) {
|
||
t.Parallel()
|
||
t.Run("失败路径(鉴权失败)", func(t *testing.T) {
|
||
t.Parallel()
|
||
srv, _ := jsonServer(t, http.StatusUnauthorized, `{"error":"invalid api key"}`)
|
||
var buf bytes.Buffer
|
||
ws := searchTool(t,
|
||
[]SearchProviderSpec{cloudSpec(search.NameZhipu, leakKey, srv.URL)},
|
||
search.NameZhipu, nil,
|
||
WithHTTPClient(srv.Client()),
|
||
WithLogger(logger.New(logger.Options{Out: &buf, Level: "debug"})),
|
||
)
|
||
_, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
|
||
if err == nil {
|
||
t.Fatal("401 必须如实报错")
|
||
}
|
||
if strings.Contains(err.Error(), leakKey) {
|
||
t.Fatalf("密钥进了错误文案: %v", err)
|
||
}
|
||
if strings.Contains(buf.String(), leakKey) {
|
||
t.Fatalf("密钥进了日志: %s", buf.String())
|
||
}
|
||
})
|
||
|
||
t.Run("成功路径", func(t *testing.T) {
|
||
t.Parallel()
|
||
srv, _ := jsonServer(t, http.StatusOK, bochaOK)
|
||
var buf bytes.Buffer
|
||
ws := searchTool(t,
|
||
[]SearchProviderSpec{cloudSpec(search.NameBocha, leakKey, srv.URL)},
|
||
search.NameBocha, nil,
|
||
WithHTTPClient(srv.Client()),
|
||
WithLogger(logger.New(logger.Options{Out: &buf, Level: "debug"})),
|
||
)
|
||
got, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
encoded, err := json.Marshal(got)
|
||
if err != nil {
|
||
t.Fatalf("marshal result: %v", err)
|
||
}
|
||
if strings.Contains(string(encoded), leakKey) {
|
||
t.Fatalf("密钥进了工具响应: %s", encoded)
|
||
}
|
||
if strings.Contains(buf.String(), leakKey) {
|
||
t.Fatalf("密钥进了日志: %s", buf.String())
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestSearch_MaxResultsOption 钉住 WEB_SEARCH_MAX_RESULTS(WithSearchMaxResults)真的
|
||
// 只约束 web-search:条数被截到配置值。
|
||
func TestSearch_MaxResultsOption(t *testing.T) {
|
||
t.Parallel()
|
||
srv, _ := jsonServer(t, http.StatusOK, `{"results":[
|
||
{"title":"A","url":"https://a.example/1"},
|
||
{"title":"B","url":"https://b.example/2"},
|
||
{"title":"C","url":"https://c.example/3"}]}`)
|
||
ws := searchTool(t,
|
||
[]SearchProviderSpec{{Name: search.NameSearXNG, BaseURL: srv.URL, Configured: true}},
|
||
search.NameSearXNG, nil,
|
||
WithHTTPClient(srv.Client()),
|
||
WithSearchMaxResults(2),
|
||
)
|
||
got, err := ws.Execute(context.Background(), map[string]any{"query": "昔涟"})
|
||
if err != nil {
|
||
t.Fatalf("Execute: %v", err)
|
||
}
|
||
if m := got.(map[string]any); m["count"].(int) != 2 {
|
||
t.Fatalf("count = %v, want 2(WEB_SEARCH_MAX_RESULTS 生效)", m["count"])
|
||
}
|
||
}
|
||
|
||
// TestSearch_ProviderOffNotRegistered 钉住「选定 provider 显式关闭」这条语义在 io 层
|
||
// 的样子:WithoutSearch 之后 web-search 不进注册面(组合根由 ResolveProvider 决定)。
|
||
func TestSearch_ProviderOffNotRegistered(t *testing.T) {
|
||
t.Parallel()
|
||
set, err := searchNew(t,
|
||
WithSearchProviders(SearchProviderSpec{Name: search.NameSearXNG, BaseURL: "http://127.0.0.1:38471", Configured: true}),
|
||
WithSearchSelection(search.NameSearXNG, nil),
|
||
WithoutSearch(),
|
||
)
|
||
if err != nil {
|
||
t.Fatalf("关闭态不该报错: %v", err)
|
||
}
|
||
if hasTool(set, "web-search") {
|
||
t.Fatal("关闭态下 web-search 不得进注册面")
|
||
}
|
||
}
|
||
|
||
// jsonServerMust 造一个固定应答的服务器,返回它本身(只要 URL 时用)。
|
||
func jsonServerMust(t *testing.T, body string) *httptest.Server {
|
||
t.Helper()
|
||
srv, _ := jsonServer(t, http.StatusOK, body)
|
||
return srv
|
||
}
|
||
|
||
// containsName 报告名字清单里有没有某一项。
|
||
func containsName(names []string, want string) bool {
|
||
for _, n := range names {
|
||
if n == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// TestSearch_GuardFollowsItsOwnAllowlist 钉住 SSRF 守卫仍在起作用、且只看**配置里那几个
|
||
// 主机**:白名单内的回环端点(本机兜底)放行,白名单外的私网地址与非 http(s) 一律拦下。
|
||
//
|
||
// 「配置里的 provider 主机可信」是 WS1-A 定下的口径(端点是运维配置、不是模型输入);
|
||
// 模型只能改查询串,改不了主机 —— 共享白名单(http / web-fetch)不受影响,由
|
||
// TestWebSearch_DoesNotWidenSharedAllowlist 钉住。
|
||
func TestSearch_GuardFollowsItsOwnAllowlist(t *testing.T) {
|
||
t.Parallel()
|
||
guard := (&tools{}).searchGuard([]string{"127.0.0.1"})
|
||
if err := guard(context.Background(), "http://127.0.0.1:38471/search?q=x&format=json"); err != nil {
|
||
t.Fatalf("白名单内的本机兜底端点必须放行: %v", err)
|
||
}
|
||
for _, rawURL := range []string{"http://10.255.255.1/search", "http://169.254.1.1/x"} {
|
||
if err := guard(context.Background(), rawURL); !errors.Is(err, ErrSSRF) {
|
||
t.Fatalf("%s 必须被 SSRF 守卫拦下, err = %v", rawURL, err)
|
||
}
|
||
}
|
||
if err := guard(context.Background(), "ftp://files.example/x"); err == nil {
|
||
t.Fatal("非 http(s) 目标必须被拦下")
|
||
}
|
||
}
|