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) 目标必须被拦下") } }